Merge pull request #1487 from NoRedInk/tessa/usage-examples-suggestions

Usage Examples
This commit is contained in:
Tessa 2023-09-08 12:01:05 -06:00 committed by GitHub
commit 7480debd7e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 883 additions and 244 deletions

View File

@ -19,20 +19,20 @@ import Json.Decode as Decode
import Nri.Ui.CssVendorPrefix.V1 as VendorPrefixed
import Nri.Ui.FocusRing.V1 as FocusRing
import Nri.Ui.Header.V1 as Header
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Html.V3 exposing (viewIf)
import Nri.Ui.MediaQuery.V1 exposing (mobile)
import Nri.Ui.Page.V3 as Page
import Nri.Ui.SideNav.V5 as SideNav
import Nri.Ui.Spacing.V1 as Spacing
import Nri.Ui.Sprite.V1 as Sprite
import Nri.Ui.UiIcon.V1 as UiIcon
import Routes
import Routes exposing (Route)
import Sort.Set as Set
import Task
import Url exposing (Url)
type alias Route =
Routes.Route Examples.State Examples.Msg
import UsageExample exposing (UsageExample)
import UsageExamples
type alias Model key =
@ -40,6 +40,7 @@ type alias Model key =
route : Route
, previousRoute : Maybe Route
, moduleStates : Dict String (Example Examples.State Examples.Msg)
, usageExampleStates : Dict String (UsageExample UsageExamples.State UsageExamples.Msg)
, isSideNavOpen : Bool
, openTooltip : Maybe TooltipId
, navigationKey : key
@ -50,14 +51,19 @@ type alias Model key =
init : () -> Url -> key -> ( Model key, Effect )
init () url key =
let
moduleStates =
Dict.fromList
(List.map (\example -> ( example.name, example )) Examples.all)
in
( { route = Routes.fromLocation moduleStates url
( { route = Routes.fromLocation url
, previousRoute = Nothing
, moduleStates = moduleStates
, moduleStates =
Dict.fromList
(List.map
(\example -> ( Example.routeName example, example ))
Examples.all
)
, usageExampleStates =
Dict.fromList
(List.map (\example -> ( UsageExample.routeName example, example ))
UsageExamples.all
)
, isSideNavOpen = False
, openTooltip = Nothing
, navigationKey = key
@ -78,6 +84,7 @@ type TooltipId
type Msg
= UpdateModuleStates String Examples.Msg
| UpdateUsageExamples String UsageExamples.Msg
| OnUrlRequest Browser.UrlRequest
| OnUrlChange Url
| ChangeRoute Route
@ -105,9 +112,6 @@ update action model =
in
{ model
| moduleStates = Dict.insert key newExample model.moduleStates
, route =
Maybe.withDefault model.route
(Routes.updateExample newExample model.route)
}
)
|> Tuple.mapSecond (Cmd.map (UpdateModuleStates key) >> Command)
@ -115,6 +119,25 @@ update action model =
Nothing ->
( model, None )
UpdateUsageExamples key exampleMsg ->
case Dict.get key model.usageExampleStates of
Just usageExample ->
usageExample.update exampleMsg usageExample.state
|> Tuple.mapFirst
(\newState ->
let
newExample =
{ usageExample | state = newState }
in
{ model
| usageExampleStates = Dict.insert key newExample model.usageExampleStates
}
)
|> Tuple.mapSecond (Cmd.map (UpdateUsageExamples key) >> Command)
Nothing ->
( model, None )
OnUrlRequest request ->
case request of
Internal loc ->
@ -126,14 +149,14 @@ update action model =
OnUrlChange location ->
let
route =
Routes.fromLocation model.moduleStates location
Routes.fromLocation location
in
( { model
| route = route
, previousRoute = Just model.route
, isSideNavOpen = False
}
, Maybe.map FocusOn (Routes.headerId route)
, Maybe.map FocusOn (Routes.headerId route model.moduleStates model.usageExampleStates)
|> Maybe.withDefault None
)
@ -233,13 +256,32 @@ perform navigationKey effect =
subscriptions : Model key -> Sub Msg
subscriptions model =
let
exampleSubs exampleName =
case Dict.get exampleName model.moduleStates of
Just example ->
Sub.map (UpdateModuleStates exampleName)
(example.subscriptions example.state)
Nothing ->
Sub.none
in
Sub.batch
[ case model.route of
Routes.Doodad example ->
Sub.map (UpdateModuleStates example.name) (example.subscriptions example.state)
Routes.Doodad exampleName ->
exampleSubs exampleName
Routes.CategoryDoodad _ example ->
Sub.map (UpdateModuleStates example.name) (example.subscriptions example.state)
Routes.CategoryDoodad _ exampleName ->
exampleSubs exampleName
Routes.Usage exampleName ->
case Dict.get exampleName model.usageExampleStates of
Just example ->
Sub.map (UpdateUsageExamples exampleName)
(example.subscriptions example.state)
Nothing ->
Sub.none
_ ->
Sub.none
@ -260,28 +302,49 @@ view model =
, Css.Global.body [ Css.margin Css.zero ]
]
]
exampleDocument exampleName =
case Dict.get exampleName model.moduleStates of
Just example ->
{ title = example.name ++ " in the NoRedInk Component Catalog"
, body = viewExample model example |> toBody
}
Nothing ->
{ title =
"Component example \""
++ Example.fromRouteName exampleName
++ "\" was not found in the NoRedInk Component Catalog"
, body = toBody notFound
}
in
case model.route of
Routes.Doodad example ->
{ title = example.name ++ " in the NoRedInk Component Catalog"
, body = viewExample model example |> toBody
}
Routes.Doodad exampleName ->
exampleDocument exampleName
Routes.CategoryDoodad _ example ->
{ title = example.name ++ " in the NoRedInk Component Catalog"
, body = viewExample model example |> toBody
}
Routes.NotFound name ->
{ title = name ++ " was not found in the NoRedInk Component Catalog"
, body = toBody notFound
}
Routes.CategoryDoodad _ exampleName ->
exampleDocument exampleName
Routes.Category category ->
{ title = Category.forDisplay category ++ " Category in the NoRedInk Component Catalog"
, body = toBody (viewCategory model category)
}
Routes.Usage exampleName ->
case Dict.get exampleName model.usageExampleStates of
Just example ->
{ title = example.name ++ " Usage Example in the NoRedInk Component Catalog"
, body = viewUsageExample model example |> toBody
}
Nothing ->
{ title =
"Usage example \""
++ UsageExample.fromRouteName exampleName
++ "\" was not found in the NoRedInk Component Catalog"
, body = toBody notFound
}
Routes.All ->
{ title = "NoRedInk Component Catalog"
, body = toBody (viewAll model)
@ -295,6 +358,13 @@ viewExample model example =
|> viewLayout model [ Example.extraLinks (UpdateModuleStates example.name) example ]
viewUsageExample : Model key -> UsageExample a UsageExamples.Msg -> Html Msg
viewUsageExample model example =
UsageExample.view example
|> Html.map (UpdateUsageExamples (UsageExample.routeName example))
|> viewLayout model []
notFound : Html Msg
notFound =
Page.notFound
@ -306,37 +376,54 @@ notFound =
viewAll : Model key -> Html Msg
viewAll model =
viewLayout model [] <|
viewPreviews "all"
viewExamplePreviews "all"
{ swallowEvent = SwallowEvent
, navigate = Routes.Doodad >> ChangeRoute
, exampleHref = Routes.Doodad >> Routes.toString
, navigate = Example.routeName >> Routes.Doodad >> ChangeRoute
, exampleHref = Example.routeName >> Routes.Doodad >> Routes.toString
}
{ swallowEvent = SwallowEvent
, navigate = UsageExample.routeName >> Routes.Usage >> ChangeRoute
, exampleHref = UsageExample.routeName >> Routes.Usage >> Routes.toString
}
(Dict.values model.moduleStates)
(Dict.values model.usageExampleStates)
viewCategory : Model key -> Category -> Html Msg
viewCategory model category =
viewLayout model [] <|
(model.moduleStates
|> Dict.values
|> List.filter
(\doodad ->
let
filtered items =
List.filter
(\item ->
Set.memberOf
(Set.fromList Category.sorter doodad.categories)
(Set.fromList Category.sorter item.categories)
category
)
|> viewPreviews (Category.forId category)
{ swallowEvent = SwallowEvent
, navigate = Routes.CategoryDoodad category >> ChangeRoute
, exampleHref = Routes.CategoryDoodad category >> Routes.toString
}
)
(Dict.values items)
in
viewLayout model [] <|
viewExamplePreviews (Category.forId category)
{ swallowEvent = SwallowEvent
, navigate = Example.routeName >> Routes.CategoryDoodad category >> ChangeRoute
, exampleHref = Example.routeName >> Routes.CategoryDoodad category >> Routes.toString
}
{ swallowEvent = SwallowEvent
, navigate = UsageExample.routeName >> Routes.Usage >> ChangeRoute
, exampleHref = UsageExample.routeName >> Routes.Usage >> Routes.toString
}
(filtered model.moduleStates)
(filtered model.usageExampleStates)
viewLayout : Model key -> List (Header.Attribute (Routes.Route Examples.State Examples.Msg) Msg) -> Html Msg -> Html Msg
viewLayout : Model key -> List (Header.Attribute Route Msg) -> Html Msg -> Html Msg
viewLayout model headerExtras content =
Html.div []
[ Html.header [] [ Routes.viewHeader model.route headerExtras ]
[ Html.header []
[ Routes.viewHeader model.route
model.moduleStates
model.usageExampleStates
headerExtras
]
, Html.div
[ css
[ displayFlex
@ -359,27 +446,47 @@ viewLayout model headerExtras content =
]
viewPreviews :
viewExamplePreviews :
String
->
{ swallowEvent : Msg
, navigate : Example Examples.State Examples.Msg -> Msg
, exampleHref : Example Examples.State Examples.Msg -> String
}
->
{ swallowEvent : Msg
, navigate : UsageExample UsageExamples.State UsageExamples.Msg -> Msg
, exampleHref : UsageExample UsageExamples.State UsageExamples.Msg -> String
}
-> List (Example Examples.State Examples.Msg)
-> List (UsageExample UsageExamples.State UsageExamples.Msg)
-> Html Msg
viewPreviews containerId navConfig examples =
examples
|> List.map (Example.preview navConfig)
|> Html.div
[ id containerId
, css
[ Css.displayFlex
, Css.flexWrap Css.wrap
, Css.property "row-gap" (.value Spacing.verticalSpacerPx)
, Css.property "column-gap" (.value Spacing.horizontalSpacerPx)
]
viewExamplePreviews containerId exampleNavConfig usageNavConfig examples usageExamples =
Html.div [ id containerId ]
[ Heading.h2 [ Heading.plaintext "Components" ]
, examplesContainer (List.map (Example.preview exampleNavConfig) examples)
, viewIf
(\_ ->
Heading.h2
[ Heading.plaintext "Usage Examples"
, Heading.css [ Css.marginTop (Css.px 30) ]
]
)
(List.length usageExamples > 0)
, examplesContainer (List.map (UsageExample.preview usageNavConfig) usageExamples)
]
examplesContainer : List (Html msg) -> Html msg
examplesContainer =
Html.div
[ css
[ Css.displayFlex
, Css.flexWrap Css.wrap
, Css.property "row-gap" (.value Spacing.verticalSpacerPx)
, Css.property "column-gap" (.value Spacing.horizontalSpacerPx)
]
]
navigation : Model key -> Html Msg
@ -393,7 +500,8 @@ navigation { moduleStates, route, isSideNavOpen, openTooltip } =
|> List.map
(\example ->
SideNav.entry example.name
[ SideNav.href (Routes.CategoryDoodad category example)
[ SideNav.href
(Routes.CategoryDoodad category (Example.routeName example))
]
)

View File

@ -1,4 +1,4 @@
module Example exposing (Example, extraLinks, fullName, preview, view, wrapMsg, wrapState)
module Example exposing (Example, extraLinks, fromRouteName, fullName, preview, routeName, view, wrapMsg, wrapState)
import Accessibility.Styled.Aria as Aria
import Category exposing (Category)
@ -39,6 +39,16 @@ fullName example =
"Nri.Ui." ++ example.name ++ ".V" ++ String.fromInt example.version
routeName : { example | name : String } -> String
routeName example =
String.replace " " "-" example.name
fromRouteName : String -> String
fromRouteName name =
String.replace "-" " " name
wrapMsg :
(msg -> msg2)
-> (msg2 -> Maybe msg)

View File

@ -77,7 +77,7 @@ example =
, about =
[ let
url =
Routes.toString <| Routes.Doodad RadioButtonDotlessExample.example
Routes.exampleHref RadioButtonDotlessExample.example
in
Message.view
[ Message.markdown <| "Looking for a group of buttons where only one button is selectable at a time? Check out [RadioButtonDotless](" ++ url ++ ")"

View File

@ -59,7 +59,7 @@ example =
, about =
[ let
url =
Routes.toString <| Routes.Doodad RadioButtonDotlessExample.example
Routes.exampleHref RadioButtonDotlessExample.example
in
Message.view
[ Message.markdown <| "Looking for radio button that's styled more like a button?<br />Check out [RadioButtonDotless](" ++ url ++ ")"

View File

@ -18,7 +18,6 @@ import Debug.Control.View as ControlView
import EllieLink
import Example exposing (Example)
import Html.Styled.Attributes exposing (css, href, id)
import Html.Styled.Events as Events
import KeyboardSupport exposing (Key(..))
import Markdown
import Nri.Ui.ClickableSvg.V2 as ClickableSvg
@ -29,6 +28,8 @@ import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.Table.V7 as Table
import Nri.Ui.Tooltip.V3 as Tooltip
import Nri.Ui.UiIcon.V1 as UiIcon
import Routes
import UsageExamples.ClickableCardWithTooltip
version : Int
@ -94,7 +95,6 @@ example =
type alias State =
{ openTooltip : Maybe TooltipId
, staticExampleSettings : Control (List ( String, Tooltip.Attribute Never ))
, disclosureModel : { parentClicks : Int }
, pageSettings : Control PageSettings
}
@ -103,7 +103,6 @@ init : State
init =
{ openTooltip = Nothing
, staticExampleSettings = initStaticExampleSettings
, disclosureModel = { parentClicks = 0 }
, pageSettings =
Control.record PageSettings
|> Control.field "backgroundColor"
@ -132,11 +131,6 @@ type Msg
| SetControl (Control (List ( String, Tooltip.Attribute Never )))
| UpdatePageSettings (Control PageSettings)
| Log String
| DisclosureMsg DisclosureMsg
type DisclosureMsg
= ParentClick
update : Msg -> State -> ( State, Cmd Msg )
@ -158,9 +152,6 @@ update msg model =
Log message ->
( Debug.log "Tooltip Log:" |> always model, Cmd.none )
DisclosureMsg ParentClick ->
( { model | disclosureModel = { parentClicks = model.disclosureModel.parentClicks + 1 } }, Cmd.none )
view : EllieLink.Config -> State -> List (Html Msg)
view ellieLinkConfig model =
@ -244,12 +235,14 @@ Use when all of the following are true:
This type may contain interactive elements such as links.
"""
, description =
"""
Sometimes a tooltip trigger doesn't have any functionality itself outside of revealing information.
This behavior is analogous to disclosure behavior, except that it's presented different visually. (For more information, please read [Sarah Higley's "Tooltips in the time of WCAG 2.1" post](https://sarahmhigley.com/writing/tooltips-in-wcag-21).)
"""
, example = viewDisclosureToolip model.openTooltip model.disclosureModel
[ "Sometimes a tooltip trigger doesn't have any functionality itself outside of revealing information.\n\n"
, "This behavior is analogous to disclosure behavior, except that it's presented different visually. (For more information, please read [Sarah Higley's \"Tooltips in the time of WCAG 2.1\" post](https://sarahmhigley.com/writing/tooltips-in-wcag-21).)\n\n"
, "Are you trying to use this tooltip type inside a clickable card? Check out [the Clickable Card with Tooltip example]("
, Routes.usageExampleHref UsageExamples.ClickableCardWithTooltip.example
, ")."
]
|> String.join ""
, example = viewDisclosureToolip model.openTooltip
, tooltipId = Disclosure
}
, { name = "Tooltip.viewToggleTip"
@ -262,9 +255,12 @@ This type may contain interactive elements such as links.
"""
, description =
"""
This is a helper for using Tooltip.disclosure with a "?" icon because it is a commonly used UI pattern. We use this helper when we want to show more information about an element but we don't want the element itself to have its own tooltip. The "?" icon typically appears visually adjacent to the element it reveals information about.
"""
[ "This is a helper for using Tooltip.disclosure with a \"?\" icon because it is a commonly used UI pattern. We use this helper when we want to show more information about an element but we don't want the element itself to have its own tooltip. The \"?\" icon typically appears visually adjacent to the element it reveals information about.\n\n"
, "Are you trying to use this tooltip type inside a clickable card? Check out [the Clickable Card with Tooltip example]("
, Routes.usageExampleHref UsageExamples.ClickableCardWithTooltip.example
, ")."
]
|> String.join ""
, example = viewToggleTip model.openTooltip
, tooltipId = LearnMore
}
@ -315,8 +311,8 @@ viewAuxillaryDescriptionToolip openTooltip =
]
viewDisclosureToolip : Maybe TooltipId -> { parentClicks : Int } -> Html Msg
viewDisclosureToolip openTooltip { parentClicks } =
viewDisclosureToolip : Maybe TooltipId -> Html Msg
viewDisclosureToolip openTooltip =
let
triggerId =
"tooltip__disclosure-trigger"
@ -324,36 +320,29 @@ viewDisclosureToolip openTooltip { parentClicks } =
lastId =
"tooltip__disclosure-what-is-mastery"
in
Html.button
[ css [ Css.padding (Css.px 40) ]
, Events.onClick (DisclosureMsg ParentClick)
, id "parent-button"
]
[ Tooltip.view
{ id = "tooltip__disclosure"
, trigger =
\eventHandlers ->
ClickableSvg.button "Previously mastered"
(Svg.withColor Colors.green UiIcon.starFilled)
[ ClickableSvg.custom eventHandlers
, ClickableSvg.id triggerId
]
}
[ Tooltip.html
[ Html.text "You mastered this skill in a previous year! Way to go! "
, Html.a
[ id lastId
, href "https://noredink.zendesk.com/hc/en-us/articles/203022319-What-is-mastery-"
Tooltip.view
{ id = "tooltip__disclosure"
, trigger =
\eventHandlers ->
ClickableSvg.button "Previously mastered"
(Svg.withColor Colors.green UiIcon.starFilled)
[ ClickableSvg.custom eventHandlers
, ClickableSvg.id triggerId
]
[ Html.text "Learn more about NoRedInk Mastery" ]
}
[ Tooltip.html
[ Html.text "You mastered this skill in a previous year! Way to go! "
, Html.a
[ id lastId
, href "https://noredink.zendesk.com/hc/en-us/articles/203022319-What-is-mastery-"
]
, Tooltip.disclosure { triggerId = triggerId, lastId = Just lastId }
, Tooltip.onToggle (ToggleTooltip Disclosure)
, Tooltip.open (openTooltip == Just Disclosure)
, Tooltip.smallPadding
, Tooltip.alignEndForMobile (Css.px 148)
[ Html.text "Learn more about NoRedInk Mastery" ]
]
, Html.div [ id "parent-button-clicks" ] [ Html.text ("Parent Clicks: " ++ String.fromInt parentClicks) ]
, Tooltip.disclosure { triggerId = triggerId, lastId = Just lastId }
, Tooltip.onToggle (ToggleTooltip Disclosure)
, Tooltip.open (openTooltip == Just Disclosure)
, Tooltip.smallPadding
, Tooltip.alignEndForMobile (Css.px 148)
]

View File

@ -1,4 +1,18 @@
module Routes exposing (Route(..), fromLocation, headerId, toString, updateExample, viewHeader)
module Routes exposing
( Route(..), toString, fromLocation
, viewHeader, headerId
, exampleRoute
, exampleHref, usageExampleHref
)
{-|
@docs Route, toString, fromLocation
@docs viewHeader, headerId
@docs exampleRoute
@docs exampleHref, usageExampleHref
-}
import Accessibility.Styled as Html exposing (Html)
import Category
@ -11,46 +25,40 @@ import Nri.Ui.Header.V1 as Header
import Nri.Ui.Html.Attributes.V2 exposing (safeIdWithPrefix)
import Parser exposing ((|.), (|=), Parser)
import Url exposing (Url)
import UsageExample exposing (UsageExample)
type Route state msg
= Doodad (Example state msg)
type Route
= Doodad String
| Category Category.Category
| CategoryDoodad Category.Category (Example state msg)
| CategoryDoodad Category.Category String
| Usage String
| All
| NotFound String
toString : Route state msg -> String
toString : Route -> String
toString route_ =
case route_ of
Doodad example ->
"#/doodad/" ++ example.name
Doodad exampleName ->
"#/doodad/" ++ exampleName
Category c ->
"#/category/" ++ Category.forRoute c
CategoryDoodad c example ->
"#/category_doodad/" ++ Category.forRoute c ++ "/" ++ example.name
CategoryDoodad c exampleName ->
"#/category_doodad/" ++ Category.forRoute c ++ "/" ++ exampleName
Usage exampleName ->
"#/usage_example/" ++ exampleName
All ->
"#/"
NotFound unmatchedRoute ->
unmatchedRoute
route : Dict String (Example state msg) -> Parser (Route state msg)
route examples =
let
findExample : (Example state msg -> Route state msg) -> String -> Route state msg
findExample toRoute name =
Dict.get name examples
|> Maybe.map toRoute
|> Maybe.withDefault (NotFound name)
in
route : Parser Route
route =
Parser.oneOf
[ Parser.succeed (\cat -> findExample (CategoryDoodad cat))
[ Parser.succeed CategoryDoodad
|. Parser.token "/category_doodad/"
|= (Parser.getChompedString (Parser.chompWhile ((/=) '/'))
|> Parser.andThen category
@ -60,9 +68,12 @@ route examples =
, Parser.succeed Category
|. Parser.token "/category/"
|= (restOfPath |> Parser.andThen category)
, Parser.succeed (findExample Doodad)
, Parser.succeed Doodad
|. Parser.token "/doodad/"
|= restOfPath
, Parser.succeed Usage
|. Parser.token "/usage_example/"
|= restOfPath
, Parser.succeed All
]
@ -82,30 +93,22 @@ category string =
Parser.problem e
updateExample : Example state msg -> Route state msg -> Maybe (Route state msg)
updateExample example route_ =
case route_ of
Doodad _ ->
Just (Doodad example)
CategoryDoodad cat _ ->
Just (CategoryDoodad cat example)
_ ->
Nothing
fromLocation : Dict String (Example state msg) -> Url -> Route state msg
fromLocation examples location =
fromLocation : Url -> Route
fromLocation location =
location.fragment
|> Maybe.withDefault ""
|> Parser.run (route examples)
|> Parser.run route
|> Result.withDefault All
viewHeader : Route state msg -> List (Header.Attribute (Route state msg) msg2) -> Html msg2
viewHeader currentRoute extraContent =
breadCrumbs currentRoute
viewHeader :
Route
-> Dict String (Example state msg)
-> Dict String (UsageExample usageState usageMsg)
-> List (Header.Attribute Route msg2)
-> Html msg2
viewHeader currentRoute examples usageExamples extraContent =
breadCrumbs currentRoute examples usageExamples
|> Maybe.map
(\crumbs ->
Header.view
@ -121,13 +124,21 @@ viewHeader currentRoute extraContent =
|> Maybe.withDefault (Html.text "")
headerId : Route state msg -> Maybe String
headerId route_ =
Maybe.map BreadCrumbs.headerId (breadCrumbs route_)
headerId :
Route
-> Dict String (Example state msg)
-> Dict String (UsageExample usageState usageMsg)
-> Maybe String
headerId route_ examples usageExamples =
Maybe.map BreadCrumbs.headerId (breadCrumbs route_ examples usageExamples)
breadCrumbs : Route state msg -> Maybe (BreadCrumbs (Route state msg))
breadCrumbs route_ =
breadCrumbs :
Route
-> Dict String (Example state msg)
-> Dict String (UsageExample usageState usageMsg)
-> Maybe (BreadCrumbs Route)
breadCrumbs route_ examples usageExamples =
case route_ of
All ->
Just allBreadCrumb
@ -135,17 +146,21 @@ breadCrumbs route_ =
Category category_ ->
Just (categoryCrumb category_)
Doodad example ->
Just (doodadCrumb allBreadCrumb example)
Doodad exampleName ->
Maybe.map (doodadCrumb allBreadCrumb) (Dict.get exampleName examples)
CategoryDoodad category_ example ->
Just (doodadCrumb (categoryCrumb category_) example)
CategoryDoodad category_ exampleName ->
Maybe.map
(\example ->
doodadCrumb (categoryCrumb category_) example
)
(Dict.get exampleName examples)
NotFound _ ->
Nothing
Usage exampleName ->
Maybe.map usageExampleCrumb (Dict.get exampleName usageExamples)
allBreadCrumb : BreadCrumbs (Route state msg)
allBreadCrumb : BreadCrumbs Route
allBreadCrumb =
BreadCrumbs.init
{ id = "breadcrumbs__all"
@ -155,7 +170,7 @@ allBreadCrumb =
[]
categoryCrumb : Category.Category -> BreadCrumbs (Route state msg)
categoryCrumb : Category.Category -> BreadCrumbs Route
categoryCrumb category_ =
BreadCrumbs.after allBreadCrumb
{ id = "breadcrumbs__" ++ Category.forId category_
@ -165,11 +180,41 @@ categoryCrumb category_ =
[]
doodadCrumb : BreadCrumbs (Route state msg) -> Example state msg -> BreadCrumbs (Route state msg)
doodadCrumb : BreadCrumbs Route -> Example state msg -> BreadCrumbs Route
doodadCrumb previous example =
BreadCrumbs.after previous
{ id = safeIdWithPrefix "breadcrumbs" example.name
, text = Example.fullName example
, route = Doodad example
, route = Doodad (Example.routeName example)
}
[]
usageExampleCrumb : UsageExample a b -> BreadCrumbs Route
usageExampleCrumb example =
BreadCrumbs.after allBreadCrumb
{ id = safeIdWithPrefix "breadcrumbs" example.name
, text = UsageExample.fullName example
, route = Usage (UsageExample.routeName example)
}
[]
exampleRoute : Example a b -> Route
exampleRoute example =
Doodad (Example.routeName example)
exampleHref : Example a b -> String
exampleHref =
exampleRoute >> toString
usageExampleRoute : UsageExample a b -> Route
usageExampleRoute example =
Usage (UsageExample.routeName example)
usageExampleHref : UsageExample a b -> String
usageExampleHref =
usageExampleRoute >> toString

View File

@ -0,0 +1,173 @@
module UsageExample exposing (UsageExample, fromRouteName, fullName, preview, routeName, view, wrapMsg, wrapState)
import Category exposing (Category)
import Css
import Css.Media exposing (withMedia)
import EventExtras
import ExampleSection
import Html.Styled as Html exposing (Html)
import Html.Styled.Attributes as Attributes
import Html.Styled.Events as Events
import Html.Styled.Lazy as Lazy
import Nri.Ui.ClickableText.V3 as ClickableText
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Container.V2 as Container
import Nri.Ui.MediaQuery.V1 exposing (mobile)
import Nri.Ui.Text.V6 as Text
type alias UsageExample state msg =
{ name : String
, state : state
, update : msg -> state -> ( state, Cmd msg )
, subscriptions : state -> Sub msg
, view : state -> List (Html msg)
, about : List (Html Never)
, categories : List Category
}
fullName : { example | name : String } -> String
fullName example =
example.name
routeName : { example | name : String } -> String
routeName example =
String.replace " " "-" example.name
fromRouteName : String -> String
fromRouteName name =
String.replace "-" " " name
wrapMsg :
(msg -> msg2)
-> (msg2 -> Maybe msg)
-> UsageExample state msg
-> UsageExample state msg2
wrapMsg wrapMsg_ unwrapMsg example =
{ name = example.name
, state = example.state
, update =
\msg2 state ->
case unwrapMsg msg2 of
Just msg ->
example.update msg state
|> Tuple.mapSecond (Cmd.map wrapMsg_)
Nothing ->
( state, Cmd.none )
, subscriptions = \state -> Sub.map wrapMsg_ (example.subscriptions state)
, view =
\state ->
List.map (Html.map wrapMsg_)
(example.view state)
, about = example.about
, categories = example.categories
}
wrapState :
(state -> state2)
-> (state2 -> Maybe state)
-> UsageExample state msg
-> UsageExample state2 msg
wrapState wrapState_ unwrapState example =
{ name = example.name
, state = wrapState_ example.state
, update =
\msg state2 ->
case unwrapState state2 of
Just state ->
example.update msg state
|> Tuple.mapFirst wrapState_
Nothing ->
( state2, Cmd.none )
, subscriptions =
unwrapState
>> Maybe.map example.subscriptions
>> Maybe.withDefault Sub.none
, view =
\state ->
Maybe.map example.view (unwrapState state)
|> Maybe.withDefault []
, about = example.about
, categories = example.categories
}
preview :
{ swallowEvent : msg2
, navigate : UsageExample state msg -> msg2
, exampleHref : UsageExample state msg -> String
}
-> UsageExample state msg
-> Html msg2
preview navConfig =
Lazy.lazy (preview_ navConfig)
preview_ :
{ swallowEvent : msg2
, navigate : UsageExample state msg -> msg2
, exampleHref : UsageExample state msg -> String
}
-> UsageExample state msg
-> Html msg2
preview_ { swallowEvent, navigate, exampleHref } example =
Container.view
[ Container.gray
, Container.css
[ Css.flexBasis (Css.px 200)
, Css.flexShrink Css.zero
, Css.hover
[ Css.backgroundColor Colors.glacier
, Css.cursor Css.pointer
]
]
, Container.custom [ Events.onClick (navigate example) ]
, Container.html
[ Html.span [ EventExtras.onClickStopPropagation swallowEvent ]
[ ClickableText.link example.name
[ ClickableText.href (exampleHref example)
, ClickableText.nriDescription "usage-example-link"
]
]
]
]
view : UsageExample state msg -> Html msg
view example =
Html.div [ Attributes.id (String.replace " " "-" example.name) ]
(view_ example)
view_ : UsageExample state msg -> List (Html msg)
view_ example =
[ Html.div
[ Attributes.css
[ Css.displayFlex
, Css.alignItems Css.stretch
, Css.flexWrap Css.wrap
, Css.property "gap" "10px"
, withMedia [ mobile ] [ Css.flexDirection Css.column, Css.alignItems Css.stretch ]
]
]
[ ExampleSection.sectionWithCss "About"
[ Css.flex (Css.int 1) ]
viewAbout
example.about
]
, Html.div [ Attributes.css [ Css.marginBottom (Css.px 200) ] ]
(example.view example.state)
]
viewAbout : List (Html Never) -> Html msg
viewAbout about =
Text.mediumBody [ Text.html about ]
|> Html.map never

View File

@ -0,0 +1,58 @@
module UsageExamples exposing (Msg, State, all)
import UsageExample exposing (UsageExample)
import UsageExamples.ClickableCardWithTooltip as ClickableCardWithTooltip
import UsageExamples.Form as Form
all : List (UsageExample State Msg)
all =
[ ClickableCardWithTooltip.example
|> UsageExample.wrapMsg ClickableCardWithTooltipMsg
(\msg ->
case msg of
ClickableCardWithTooltipMsg childMsg ->
Just childMsg
_ ->
Nothing
)
|> UsageExample.wrapState ClickableCardWithTooltipState
(\msg ->
case msg of
ClickableCardWithTooltipState childState ->
Just childState
_ ->
Nothing
)
, Form.example
|> UsageExample.wrapMsg FormMsg
(\msg ->
case msg of
FormMsg childMsg ->
Just childMsg
_ ->
Nothing
)
|> UsageExample.wrapState FormState
(\msg ->
case msg of
FormState childState ->
Just childState
_ ->
Nothing
)
]
type State
= ClickableCardWithTooltipState ClickableCardWithTooltip.State
| FormState Form.State
type Msg
= ClickableCardWithTooltipMsg ClickableCardWithTooltip.Msg
| FormMsg Form.Msg

View File

@ -0,0 +1,99 @@
module UsageExamples.ClickableCardWithTooltip exposing (example, State, Msg)
{-|
@docs example, State, Msg
-}
import Category exposing (Category(..))
import Css
import Html.Styled exposing (Html)
import Html.Styled.Events as Events
import Nri.Ui.ClickableText.V3 as ClickableText
import Nri.Ui.Container.V2 as Container
import Nri.Ui.Text.V6 as Text
import Nri.Ui.Tooltip.V3 as Tooltip
import UsageExample exposing (UsageExample)
example : UsageExample State Msg
example =
{ name = "Clickable Card with Tooltip"
, categories = [ Messaging ]
, state = init
, update = update
, subscriptions = \_ -> Sub.none
, about = []
, view = view
}
type alias State =
{ openTooltip : Maybe Tooltip
, parentClicks : Int
}
init : State
init =
{ openTooltip = Nothing
, parentClicks = 0
}
type alias Tooltip =
()
type Msg
= ToggleTooltip Tooltip Bool
| ParentClick
update : Msg -> State -> ( State, Cmd Msg )
update msg model =
case msg of
ToggleTooltip tooltip True ->
( { model | openTooltip = Just tooltip }, Cmd.none )
ToggleTooltip _ False ->
( { model | openTooltip = Nothing }, Cmd.none )
ParentClick ->
( { model | parentClicks = model.parentClicks + 1 }
, Cmd.none
)
view : State -> List (Html Msg)
view model =
[ Container.view
[ Container.buttony
, Container.html
[ Text.smallBody
[ Text.html
[ ClickableText.button "Click me" [ ClickableText.appearsInline ]
, viewTooltip model
]
]
, Text.smallBody
[ Text.plaintext "or click anywhere in the Container!"
, Text.id "container-element"
]
]
, Container.custom [ Events.onClick ParentClick ]
, Container.css [ Css.maxWidth (Css.px 500) ]
]
, Text.mediumBody [ Text.plaintext ("Parent Clicks: " ++ String.fromInt model.parentClicks) ]
]
viewTooltip : State -> Html Msg
viewTooltip model =
Tooltip.viewToggleTip { label = "Tooltip trigger", lastId = Nothing }
[ Tooltip.plaintext "Notice that even though this tooltip is in a clickable card, you can still interact with me!"
, Tooltip.onToggle (ToggleTooltip ())
, Tooltip.open (model.openTooltip == Just ())
, Tooltip.onRight
]

View File

@ -0,0 +1,106 @@
module UsageExamples.Form exposing (example, State, Msg)
{-|
@docs example, State, Msg
-}
import Accessibility.Styled exposing (..)
import Category exposing (Category(..))
import Css
import Dict exposing (Dict)
import Html.Styled exposing (Html)
import Html.Styled.Attributes exposing (css)
import Nri.Ui.Select.V9 as Select
import Nri.Ui.TextInput.V7 as TextInput
import UsageExample exposing (UsageExample)
example : UsageExample State Msg
example =
{ name = "Form"
, categories = [ Inputs ]
, state = init
, update = update
, subscriptions = \_ -> Sub.none
, about = []
, view = view
}
type alias State =
{ title : Maybe String
, firstName : String
, lastName : String
, errors : Dict String String
}
init : State
init =
{ title = Nothing
, firstName = ""
, lastName = ""
, errors = Dict.empty
}
type Msg
= SelectTitle String
| SetFirstName String
| SetLastName String
update : Msg -> State -> ( State, Cmd Msg )
update msg model =
case msg of
SelectTitle title ->
( { model | title = Just title }
, Cmd.none
)
SetFirstName name ->
( { model | firstName = name }
, Cmd.none
)
SetLastName name ->
( { model | lastName = name }
, Cmd.none
)
view : State -> List (Html Msg)
view model =
[ form []
[ div
[ css
[ Css.displayFlex
, Css.property "gap" "10px"
]
]
[ Select.view "Title"
[ Select.value model.title
, Select.choices identity
(List.map
(\title -> { label = title, value = title })
[ "Ms.", "Mrs.", "Mr.", "Mx.", "Dr." ]
)
, Select.id "user_title"
, Select.errorMessage (Dict.get "title" model.errors)
]
|> map SelectTitle
, TextInput.view "First name"
[ TextInput.value model.firstName
, TextInput.givenName SetFirstName
, TextInput.errorMessage (Dict.get "first_name" model.errors)
]
, TextInput.view "Last name"
[ TextInput.value model.lastName
, TextInput.familyName SetLastName
, TextInput.errorMessage (Dict.get "last_name" model.errors)
]
]
]
]

View File

@ -1,6 +1,6 @@
module HighlighterExampleSpec exposing (suite)
import Examples.Highlighter exposing (Msg, State, example)
import Examples.Highlighter exposing (example)
import MouseHelpers
import ProgramTest exposing (..)
import PseudoElements exposing (hasAfter, hasBefore)
@ -10,9 +10,9 @@ import Test.Html.Selector exposing (..)
import TestApp exposing (app)
route : Route State Msg
route : Route
route =
Routes.Doodad example
Routes.exampleRoute example
suite : Test

View File

@ -2,7 +2,7 @@ module SwitchExampleSpec exposing (suite)
import Accessibility.Aria as Aria
import Accessibility.Role as Role
import Examples.Switch exposing (Msg, State, example)
import Examples.Switch exposing (example)
import ProgramTest exposing (..)
import Routes exposing (Route)
import Test exposing (..)
@ -12,9 +12,9 @@ import Test.Html.Selector exposing (..)
import TestApp exposing (app)
route : Route State Msg
route : Route
route =
Routes.Doodad example
Routes.exampleRoute example
suite : Test

View File

@ -46,6 +46,12 @@ describe("UI tests", function () {
server.close();
});
const hasText = async (xPathSelector = "//html", text) => {
let [node] = await page.$x(xPathSelector);
let innerText = await page.evaluate((el) => el.innerText, node);
assert.equal(innerText, text);
};
const handlePageErrors = function (page) {
page.on("pageerror", (err) => {
console.log("Error from page:", err.toString());
@ -68,7 +74,7 @@ describe("UI tests", function () {
}
};
const goTo = async (name, location) => {
const goToExample = async (name, location) => {
await page.goto(location, { waitUntil: "load" });
await page.waitForXPath(
`//h1[contains(., 'Nri.Ui.${name}') and @aria-current='page']`,
@ -77,7 +83,7 @@ describe("UI tests", function () {
};
const defaultProcessing = async (name, location) => {
await goTo(name, location);
await goToExample(name, location);
await percySnapshot(page, name);
const results = await new AxePuppeteer(page)
@ -86,6 +92,20 @@ describe("UI tests", function () {
handleAxeResults(name, results);
};
const defaultUsageExampleProcessing = async (testName, name, location) => {
await page.goto(location, { waitUntil: "load" });
await page.waitForXPath(
`//h1[contains(., '${name}') and @aria-current='page']`,
200
);
await percySnapshot(page, name);
const results = await new AxePuppeteer(page)
.disableRules(skippedRules[testName] || [])
.analyze();
handleAxeResults(name, results);
};
const forAllOptions = async (labelName, callback) => {
await page.waitForXPath(
`//label[contains(., '${labelName}')]//select`,
@ -107,7 +127,7 @@ describe("UI tests", function () {
};
const messageProcessing = async (name, location) => {
await goTo(name, location);
await goToExample(name, location);
await percySnapshot(page, name);
var axe = await new AxePuppeteer(page)
@ -128,7 +148,7 @@ describe("UI tests", function () {
};
const modalProcessing = async (name, location) => {
await goTo(name, location);
await goToExample(name, location);
await page.click("#launch-modal");
await page.waitForSelector('[role="dialog"]');
@ -142,7 +162,7 @@ describe("UI tests", function () {
};
const pageProcessing = async (name, location) => {
await goTo(name, location);
await goToExample(name, location);
var axe = await new AxePuppeteer(page)
.disableRules(skippedRules[name] || [])
@ -170,59 +190,49 @@ describe("UI tests", function () {
handleAxeResults(name, results);
};
const tooltipProcessing = async (name, location) => {
await defaultProcessing(name, location);
await page.waitForSelector("#parent-button-clicks");
const buttonClick = async () => {
const button = await page.$("#parent-button");
await page.evaluate((el) => el.click(), button);
const clickableCardWithTooltipProcessing = async (
testName,
name,
location
) => {
const hasParentClicks = async (count) => {
await page.waitForTimeout(100);
await hasText(
"//p[contains(., 'Parent Clicks')]",
`Parent Clicks: ${count}`
);
};
const getCounterText = async () => {
const counter = await page.$("#parent-button-clicks");
const text = await page.evaluate((el) => el.innerText, counter);
return text;
};
await defaultUsageExampleProcessing(testName, name, location);
const tooltipTriggerClick = async () => {
const button = await page.$("#tooltip__disclosure-trigger");
await page.evaluate((el) => el.click(), button);
await page.waitForTimeout(100);
};
await hasParentClicks(0);
await page.waitForSelector("[data-tooltip-visible=false]");
const isTooltipVisible = async () => {
let res = await page.evaluate(() => {
return (
getComputedStyle(
document.getElementById("tooltip__disclosure").parentElement
).getPropertyValue("display") != "none"
);
});
// Opening and closing the tooltip doesn't trigger the container effects
await page.hover('[aria-label="Tooltip trigger"]');
await page.waitForSelector("[data-tooltip-visible=true]");
return res;
};
await page.click('[aria-label="Tooltip trigger"]');
await page.waitForSelector("[data-tooltip-visible=false]");
assert.equal(await getCounterText(), "Parent Clicks: 0");
assert.equal(await isTooltipVisible(), false);
await tooltipTriggerClick();
assert.equal(await isTooltipVisible(), true);
await tooltipTriggerClick();
assert.equal(await isTooltipVisible(), false);
assert.equal(await getCounterText(), "Parent Clicks: 0");
await buttonClick();
assert.equal(await getCounterText(), "Parent Clicks: 1");
await buttonClick();
assert.equal(await getCounterText(), "Parent Clicks: 2");
await hasParentClicks(0);
// Clicking the button does trigger container effects
const [button] = await page.$x("//button[contains(., 'Click me')]");
await button.click();
await page.waitForSelector("[data-tooltip-visible=false]");
await hasParentClicks(1);
// Clicking the container does trigger container effects
await page.click("#container-element");
await hasParentClicks(2);
};
const skippedRules = {
// Loading's color contrast check seems to change behavior depending on whether Percy snapshots are taken or not
Loading: ["color-contrast"],
RadioButton: ["duplicate-id"],
// We need nested-interactive to test the tooltip behavior
Tooltip: ["nested-interactive"],
};
const specialProcessing = {
@ -233,33 +243,38 @@ describe("UI tests", function () {
UiIcon: iconProcessing,
Logo: iconProcessing,
Pennant: iconProcessing,
Tooltip: tooltipProcessing,
};
const specialUsageProcessing = {
ClickableCardwithTooltip: clickableCardWithTooltipProcessing,
};
it("All", async function () {
page = await browser.newPage();
if (process.env.ONLYDOODAD == "default") {
page = await browser.newPage();
await page.emulateMediaFeatures([
{ name: "prefers-reduced-motion", value: "reduce" },
]);
await page.emulateMediaFeatures([
{ name: "prefers-reduced-motion", value: "reduce" },
]);
handlePageErrors(page);
await page.goto(`http://localhost:${PORT}`, { waitUntil: "load" });
await page.$("#maincontent");
await percySnapshot(page, this.test.fullTitle());
handlePageErrors(page);
await page.goto(`http://localhost:${PORT}`, { waitUntil: "load" });
await page.$("#maincontent");
await percySnapshot(page, this.test.fullTitle());
const results = await new AxePuppeteer(page)
.disableRules([
"aria-hidden-focus",
"color-contrast",
"duplicate-id-aria",
"duplicate-id",
])
.analyze();
const results = await new AxePuppeteer(page)
.disableRules([
"aria-hidden-focus",
"color-contrast",
"duplicate-id-aria",
"duplicate-id",
])
.analyze();
page.close();
page.close();
handleAxeResults("index view", results);
handleAxeResults("index view", results);
}
});
it("Doodads", async function () {
@ -295,4 +310,40 @@ describe("UI tests", function () {
page.close();
});
it("Usage examples", async function () {
page = await browser.newPage();
await page.emulateMediaFeatures([
{ name: "prefers-reduced-motion", value: "reduce" },
]);
handlePageErrors(page);
await page.goto(`http://localhost:${PORT}`);
await page.$("#maincontent");
let links = await page.evaluate(() => {
let nodes = Array.from(
document.querySelectorAll("[data-nri-description='usage-example-link']")
);
return nodes.map((node) => [node.text, node.href]);
});
await links.reduce((acc, [name, location]) => {
return acc.then(() => {
let testName = name.replaceAll(" ", "");
if (
process.env.ONLYDOODAD == "default" ||
process.env.ONLYDOODAD == testName
) {
console.log(`Testing Usage Example ${testName}`);
let handler =
specialUsageProcessing[testName] || defaultUsageExampleProcessing;
return handler(testName, name, location);
}
});
}, Promise.resolve());
page.close();
});
});