Merge pull request #691 from NoRedInk/kraken/accordion-v3

Kraken/accordion v3
This commit is contained in:
Alex Perkins 2021-05-11 10:37:14 -04:00 committed by GitHub
commit 0ed7f7e83b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 579 additions and 94 deletions

View File

@ -8,6 +8,7 @@
"Nri.Ui",
"Nri.Ui.Accordion.V1",
"Nri.Ui.Accordion.V2",
"Nri.Ui.Accordion.V3",
"Nri.Ui.AssetPath",
"Nri.Ui.AssignmentIcon.V2",
"Nri.Ui.Button.V10",

386
src/Nri/Ui/Accordion/V3.elm Normal file
View File

@ -0,0 +1,386 @@
module Nri.Ui.Accordion.V3 exposing
( view, HeaderLevel(..)
, AccordionEntry(..), Entry
, StyleOptions, styleAccordion
)
{-|
## Changes from V2
- Do not render collapsed content
- Allow disclosure indicator to be top-aligned
- Allow fully-customizable carets
- Removes as many default styles as possible
- Change header resets to inline styles
- Replace custom passed-in styles with a class-based styling approach (for performance reasons -- getting elm-css class names is too slow.)
- Prevent default browser event on arrow keys (so that the page doesn't scroll when the focus changes)
- Support multiple levels of accordions
## Example
import Nri.Ui.DisclosureIndicator.V2 as DisclosureIndicator
view : Model -> Html Msg
view model =
div []
[ Accordion.view
{ entries =
[ AccordionEntry
{ caret = \isOpen -> DisclosureIndicator.large [ marginRight (px 8) ] isOpen
, content = \() -> text "Accordion Content"
, entryClass = "a-class-distinguishing-this-accordion-from-others-on-the-page"
, headerContent = text "Accordion Header"
, headerId = "a-unique-id-for-this-accordion-header-button"
, headerLevel = Accordion.H1
, isExpanded = model.isAccordionOpen
, toggle = Just ToggleAccordion
}
[]
]
, focus = Focus
}
, Accordion.styleAccordion
{ entryStyles = []
, entryExpandedStyles = []
, entryClosedStyles = []
, headerStyles = []
, headerExpandedStyles = []
, headerClosedStyles = []
, contentStyles = []
}
]
@docs view, HeaderLevel
@docs AccordionEntry, Entry
@docs StyleOptions, styleAccordion
-}
import Accessibility.Styled exposing (Attribute, Html, button, div, section, text)
import Accessibility.Styled.Aria as Aria
import Accessibility.Styled.Key as Key
import Accessibility.Styled.Role as Role
import Accessibility.Styled.Widget as Widget
import Css exposing (..)
import Css.Global
import Html.Styled.Attributes as Attributes
import Html.Styled.Events as Events exposing (onClick)
import Html.Styled.Keyed
import Json.Decode as Decode
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Html.Attributes.V2 as AttributesExtra
{-| -}
type alias StyleOptions =
{ entryStyles : List Style
, entryExpandedStyles : List Style
, entryClosedStyles : List Style
, headerStyles : List Style
, headerExpandedStyles : List Style
, headerClosedStyles : List Style
, contentStyles : List Style
}
{-| -}
styleAccordion : StyleOptions -> Html msg
styleAccordion styleOptions =
Css.Global.global
[ Css.Global.class accordionHeaderClass
[ margin zero, padding zero ]
, Css.Global.class accordionEntryHeaderClass
([ displayFlex
, alignItems center
, boxSizing borderBox
, minWidth (pct 100)
-- button resets
, Css.Global.withAttribute "aria-disabled=false" [ cursor pointer ]
, Css.backgroundColor Css.unset
, borderWidth Css.zero
, margin zero
-- fonts & text
, textAlign left
, Fonts.baseFont
, fontSize (px 16)
, fontWeight (int 600)
, lineHeight (num 1.2)
, Css.Global.withClass accordionEntryHeaderExpandedClass
styleOptions.headerExpandedStyles
, Css.Global.withClass accordionEntryHeaderCollapsedClass
styleOptions.headerClosedStyles
]
++ styleOptions.headerStyles
)
, Css.Global.class accordionEntryClass
([ marginBottom (px 10)
, Css.Global.withClass accordionEntryExpandedClass
styleOptions.entryExpandedStyles
, Css.Global.withClass accordionEntryCollapsedClass
styleOptions.entryClosedStyles
]
++ styleOptions.entryStyles
)
, Css.Global.class accordionEntryPanelClass
styleOptions.contentStyles
]
accordionClass : String
accordionClass =
"accordion-v3"
accordionHeaderClass : String
accordionHeaderClass =
"accordion-v3-header"
accordionEntryHeaderClass : String
accordionEntryHeaderClass =
"accordion-v3-entry-header"
accordionEntryHeaderExpandedClass : String
accordionEntryHeaderExpandedClass =
"accordion-v3-entry-header-expanded"
accordionEntryHeaderCollapsedClass : String
accordionEntryHeaderCollapsedClass =
"accordion-v3-entry-header-collapsed"
accordionEntryClass : String
accordionEntryClass =
"accordion-v3-entry"
accordionEntryExpandedClass : String
accordionEntryExpandedClass =
"accordion-v3-entry-state-expanded"
accordionEntryCollapsedClass : String
accordionEntryCollapsedClass =
"accordion-v3-entry-state-collapsed"
accordionEntryPanelClass : String
accordionEntryPanelClass =
"accordion-v3-entry-panel"
{-| Corresponds to h1, h2, h3 etc.
Choose the correct header level given your page context.
-}
type HeaderLevel
= H1
| H2
| H3
| H4
| H5
| H6
header : HeaderLevel -> Html msg -> Html msg
header headerLevel content =
case headerLevel of
H1 ->
Accessibility.Styled.h1 [ Attributes.class accordionHeaderClass ] [ content ]
H2 ->
Accessibility.Styled.h2 [ Attributes.class accordionHeaderClass ] [ content ]
H3 ->
Accessibility.Styled.h3 [ Attributes.class accordionHeaderClass ] [ content ]
H4 ->
Accessibility.Styled.h4 [ Attributes.class accordionHeaderClass ] [ content ]
H5 ->
Accessibility.Styled.h5 [ Attributes.class accordionHeaderClass ] [ content ]
H6 ->
Accessibility.Styled.h6 [ Attributes.class accordionHeaderClass ] [ content ]
{-| -}
type AccordionEntry msg
= AccordionEntry (Entry msg) (List (AccordionEntry msg))
{-| -}
type alias Entry msg =
{ caret : Bool -> Html msg
, content : () -> Html msg
, entryClass : String
, headerContent : Html msg
, headerId : String
, headerLevel : HeaderLevel
, isExpanded : Bool
, toggle : Maybe (Bool -> msg)
}
getHeaderId : AccordionEntry msg -> String
getHeaderId entry =
case entry of
AccordionEntry { headerId } _ ->
headerId
{-| -}
view :
{ entries : List (AccordionEntry msg)
, focus : String -> msg
}
-> Html msg
view { entries, focus } =
view_
{ entries = entries
, focus = focus
, leftId = Nothing
}
view_ :
{ entries : List (AccordionEntry msg)
, focus : String -> msg
, leftId : Maybe String
}
-> Html msg
view_ { entries, focus, leftId } =
let
headerIds : List String
headerIds =
List.map getHeaderId entries
arrowUpIds : List (Maybe String)
arrowUpIds =
lastHeaderId :: List.map Just headerIds
firstHeaderId : Maybe String
firstHeaderId =
List.head headerIds
lastHeaderId : Maybe String
lastHeaderId =
List.head (List.reverse headerIds)
in
div
[ Attributes.class accordionClass
, Attributes.attribute "aria-live" "polite"
]
[ Html.Styled.Keyed.node "div"
[]
(entries
|> List.map2 (\id nextEntry -> ( id, nextEntry )) arrowUpIds
|> List.foldr
(\( previousId, AccordionEntry entry_ children ) ( nextId, acc ) ->
let
node =
( "keyed-section__" ++ entry_.headerId
, viewEntry focus
{ up = previousId
, down = nextId
, right = Maybe.map getHeaderId (List.head children)
, left = leftId
}
entry_
children
)
in
( Just entry_.headerId
, node :: acc
)
)
( firstHeaderId, [] )
|> Tuple.second
)
]
viewEntry :
(String -> msg)
->
{ up : Maybe String
, down : Maybe String
, right : Maybe String
, left : Maybe String
}
-> Entry msg
-> List (AccordionEntry msg)
-> Html msg
viewEntry focus arrows ({ headerId, headerLevel, caret, headerContent, entryClass, content, isExpanded } as config) children =
let
panelId =
"accordion-panel__" ++ headerId
in
div
[ Attributes.classList
[ ( accordionEntryClass, True )
, ( entryClass, True )
, ( accordionEntryExpandedClass, isExpanded )
, ( accordionEntryCollapsedClass, not isExpanded )
]
]
[ header headerLevel <|
button
[ Attributes.id headerId
, Attributes.classList
[ ( accordionEntryHeaderClass, True )
, ( entryClass, True )
, ( accordionEntryHeaderExpandedClass, isExpanded )
, ( accordionEntryHeaderCollapsedClass, not isExpanded )
]
, Widget.disabled (config.toggle == Nothing)
, Widget.expanded isExpanded
, Aria.controls panelId
, config.toggle
|> Maybe.map (\toggle -> onClick (toggle (not isExpanded)))
|> Maybe.withDefault AttributesExtra.none
, Events.custom "keydown"
([ Maybe.map (\id -> Key.up (focus id)) arrows.up
, Maybe.map (\id -> Key.down (focus id)) arrows.down
, Maybe.map (\id -> Key.right (focus id)) arrows.right
, Maybe.map (\id -> Key.left (focus id)) arrows.left
]
|> List.filterMap identity
|> Decode.oneOf
|> Decode.map
(\event ->
{ message = event
, stopPropagation = False
, preventDefault = True
}
)
)
]
[ caret isExpanded
, headerContent
]
, section
[ Attributes.id panelId
, Aria.labelledBy headerId
, Attributes.classList
[ ( accordionEntryPanelClass, True )
, ( entryClass, True )
]
, Attributes.hidden (not isExpanded)
]
(if isExpanded then
[ content ()
, view_ { focus = focus, entries = children, leftId = Just headerId }
]
else
[]
)
]

View File

@ -10,20 +10,25 @@ module Examples.Accordion exposing
-}
import Accessibility.Styled as Html exposing (Html)
import AtomicDesignType exposing (AtomicDesignType(..))
import Browser.Dom as Dom
import Category exposing (Category(..))
import Css exposing (..)
import Dict exposing (Dict)
import Css.Global
import Example exposing (Example)
import Html.Styled as Html exposing (Html)
import Html.Styled.Attributes exposing (css)
import Html.Styled.Attributes as Attributes exposing (css, src)
import KeyboardSupport exposing (Direction(..), Key(..))
import Nri.Ui.Accordion.V2 as Accordion
import Nri.Ui.Accordion.V3 as Accordion exposing (AccordionEntry(..))
import Nri.Ui.Colors.Extra as ColorsExtra
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.DisclosureIndicator.V2 as DisclosureIndicator
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Heading.V2 as Heading
import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.Text.V4 as Text
import Nri.Ui.UiIcon.V1 as UiIcon
import Set exposing (Set)
import Task
@ -31,7 +36,7 @@ import Task
example : Example State Msg
example =
{ name = "Accordion"
, version = 2
, version = 3
, state = init
, update = update
, subscriptions = \_ -> Sub.none
@ -45,6 +50,12 @@ example =
, { keys = [ Arrow KeyboardSupport.Down ]
, result = "Moves the focus to the next accordion header button (wraps focus to the first header button)"
}
, { keys = [ Arrow KeyboardSupport.Right ]
, result = "Moves the focus to the first accordion header button in a nested list of accordions"
}
, { keys = [ Arrow KeyboardSupport.Left ]
, result = "Moves the focus to the parent accordion header button from a a nested accordion"
}
]
}
@ -52,89 +63,176 @@ example =
{-| -}
view : State -> List (Html Msg)
view model =
[ Heading.h3 [] [ Html.text "Accordion.view with default styles" ]
let
defaultCaret =
DisclosureIndicator.large [ Css.marginRight (Css.px 8) ]
in
[ Heading.h3 [] [ Html.text "Accordion.view" ]
, Accordion.view
{ entries =
[ { id = 1, title = "Entry 1", content = "Content for the first accordion" }
, { id = 2, title = "Entry 2", content = "Content for the second accordion" }
, { id = 3, title = "Super long entry that is very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very very long", content = "Content for the third accordion" }
]
|> List.map
(\entry ->
{ headerId = "accordion-entry__" ++ String.fromInt entry.id
, entry = entry
, isExpanded = Dict.get entry.id model |> Maybe.withDefault False
}
)
, headerLevel = Accordion.H4
, viewHeader = .title >> Html.text
, viewContent = \{ content } -> Text.smallBody [ Html.text content ]
, customStyles = Nothing
, toggle = \entry toExpand -> Toggle entry.id toExpand
, focus = Focus
, caret = Accordion.DefaultCaret
}
, Heading.h3 [] [ Html.text "Accordion.view with custom styles from peer reviews" ]
, Accordion.view
{ entries =
[ { id = 4
, title = "Firstname Lastname"
, content =
Html.div
[ css [ Fonts.baseFont, fontSize (px 13) ]
]
[ Html.text "has not started writing" ]
}
, { id = 5
, title = "LongFirstnameAnd EvenLongerLastname"
, content =
Html.div
[ css [ Fonts.baseFont, fontSize (px 13) ] ]
[ Html.text "has started writing" ]
}
]
|> List.map
(\entry ->
{ headerId = "accordion-entry__" ++ String.fromInt entry.id
, entry = entry
, isExpanded = Dict.get entry.id model |> Maybe.withDefault False
}
)
, headerLevel = Accordion.H4
, viewHeader = .title >> Html.text
, viewContent = .content
, customStyles =
Just
(\_ ->
{ entryStyles =
[ borderTop3 (px 1) solid Colors.gray75
, marginBottom zero
, width (px 284)
]
, entryExpandedStyles = []
, entryClosedStyles = []
, headerStyles =
[ height (px 46)
, paddingLeft (px 8)
, paddingRight (px 8)
, Css.alignItems Css.center
]
, headerExpandedStyles =
[ backgroundColor Colors.gray96
, borderRadius zero
]
, headerClosedStyles = [ backgroundColor transparent ]
, contentStyles =
[ backgroundColor Colors.gray96
, paddingLeft (px 8)
, paddingRight (px 8)
, paddingBottom (px 8)
]
[ AccordionEntry
{ caret = defaultCaret
, content = \_ -> Html.text "🍎 There are many kinds of apples! Apples are more genetically diverse than humans. The genetic diversity of apples means that to preserve delicious apple varieties, growers must use grafting rather than seeds. In the apple market, clones have already taken over! 🍏"
, entryClass = "accordion-example"
, headerContent = Html.text "Apples"
, headerId = "accordion-entry__1"
, headerLevel = Accordion.H4
, isExpanded = Set.member 1 model
, toggle = Just (Toggle 1)
}
[ AccordionEntry
{ caret = defaultCaret
, content =
\_ ->
Html.div []
[ Html.img "Basket of Gala Apples"
[ css [ Css.maxWidth (Css.px 100), Css.maxHeight (Css.px 100) ]
, src "https://upload.wikimedia.org/wikipedia/commons/thumb/4/4b/Malus-Gala.jpg/1205px-Malus-Gala.jpg"
]
, Html.a [ Attributes.href "https://en.wikipedia.org/wiki/Gala_(apple)" ]
[ Html.text "Wikipedia article on Gala Apples" ]
]
, entryClass = "accordion-example-child"
, headerContent = Html.text "Gala"
, headerId = "accordion-entry__11"
, headerLevel = Accordion.H5
, isExpanded = Set.member 11 model
, toggle = Just (Toggle 11)
}
)
, toggle = \entry toExpand -> Toggle entry.id toExpand
[]
, AccordionEntry
{ caret = defaultCaret
, content =
\_ ->
Html.div []
[ Html.img "Freshly-washed Granny Smith Apple"
[ css [ Css.maxWidth (Css.px 100), Css.maxHeight (Css.px 100) ]
, src "https://upload.wikimedia.org/wikipedia/commons/thumb/0/0e/One_Green_Apple.jpg/1280px-One_Green_Apple.jpg"
]
, Html.a [ Attributes.href "https://en.wikipedia.org/wiki/Granny_Smith" ]
[ Html.text "Wikipedia article on Granny Smith Apples" ]
]
, entryClass = "accordion-example-child"
, headerContent = Html.text "Granny Smith"
, headerId = "accordion-entry__12"
, headerLevel = Accordion.H5
, isExpanded = Set.member 12 model
, toggle = Just (Toggle 12)
}
[]
, AccordionEntry
{ caret = defaultCaret
, content =
\_ ->
Html.div []
[ Html.img "3 Fuji Apples resting on gingham fabric"
[ css [ Css.maxWidth (Css.px 100), Css.maxHeight (Css.px 100) ]
, src "https://upload.wikimedia.org/wikipedia/commons/thumb/8/81/Fuji_apples.jpg/1920px-Fuji_apples.jpg"
]
, Html.a [ Attributes.href "https://en.wikipedia.org/wiki/Fuji_(apple)" ]
[ Html.text "Wikipedia article on Fuji Apples" ]
]
, entryClass = "accordion-example-child"
, headerContent = Html.text "Fuji"
, headerId = "accordion-entry__13"
, headerLevel = Accordion.H5
, isExpanded = Set.member 13 model
, toggle = Just (Toggle 13)
}
[]
]
, AccordionEntry
{ caret = defaultCaret
, content = \_ -> Html.text "🍊 I don't know anything about oranges! Except: YUM! 🍊"
, entryClass = "accordion-example"
, headerContent = Html.text "Oranges"
, headerId = "accordion-entry__2"
, headerLevel = Accordion.H4
, isExpanded = Set.member 2 model
, toggle = Just (Toggle 2)
}
[]
, AccordionEntry
{ caret = defaultCaret
, content = \_ -> Html.text "There are many types of berries and all of them are delicious (or poisonous (or both)). Blackberries and mulberries are especially drool-worthy."
, entryClass = "accordion-example"
, headerContent = Html.text "Berries"
, headerId = "accordion-entry__4"
, headerLevel = Accordion.H5
, isExpanded = Set.member 4 model
, toggle = Just (Toggle 4)
}
[]
, AccordionEntry
{ caret =
\isOpen ->
(if isOpen then
UiIcon.tree
else
UiIcon.sprout
)
|> Svg.withWidth (Css.px 30)
|> Svg.withCss [ Css.marginRight (Css.px 8) ]
|> Svg.toHtml
, content =
\_ ->
Html.div
[ css
[ Css.backgroundColor Colors.gray92
, Css.minHeight (Css.vh 100)
, Css.padding (Css.px 20)
]
]
[ Html.a [ Attributes.href "https://en.wikipedia.org/wiki/Apple#/media/File:95apple.jpeg" ]
[ Html.img "Wild Apple" [ src "https://upload.wikimedia.org/wikipedia/commons/9/92/95apple.jpeg" ] ]
]
, entryClass = "fixed-positioning-accordion-example"
, headerContent = Html.text "Advanced Example: Expand & Scroll!"
, headerId = "accordion-entry__6"
, headerLevel = Accordion.H4
, isExpanded = Set.member 6 model
, toggle = Just (Toggle 6)
}
[]
]
, focus = Focus
, caret = Accordion.DefaultCaret
}
, Accordion.styleAccordion
{ entryStyles =
[ Css.Global.withClass "fixed-positioning-accordion-example"
[ Css.marginLeft (Css.px -20)
, Css.position Css.relative
]
, Css.Global.withClass "accordion-example-child"
[ Css.marginLeft (Css.px 16) ]
]
, entryExpandedStyles =
[ Css.Global.withClass "fixed-positioning-accordion-example"
[ Css.Global.children
[ Css.Global.h4
[ Css.position Css.sticky
, Css.property "position" "-webkit-sticky"
, Css.top (Css.px -8)
]
]
]
]
, entryClosedStyles = []
, headerStyles =
[ Css.Global.withClass "fixed-positioning-accordion-example"
[ Css.padding (Css.px 20)
]
]
, headerExpandedStyles =
[ Css.Global.withClass "fixed-positioning-accordion-example"
[ Css.backgroundColor Colors.gray96
, Css.borderRadius (Css.px 8)
, Css.boxShadow5 Css.zero Css.zero (px 10) zero (ColorsExtra.withAlpha 0.2 Colors.gray20)
]
]
, headerClosedStyles = []
, contentStyles = []
}
]
@ -148,26 +246,26 @@ type Msg
{-| -}
init : State
init =
Dict.fromList
[ ( 1, False )
, ( 2, False )
, ( 3, False )
, ( 4, False )
, ( 5, False )
]
Set.empty
{-| -}
type alias State =
Dict Int Bool
Set Int
{-| -}
update : Msg -> State -> ( State, Cmd Msg )
update msg model =
case msg of
Toggle id toExpanded ->
( Dict.insert id toExpanded model, Cmd.none )
Toggle id expand ->
( if expand then
Set.insert id model
else
Set.remove id model
, Cmd.none
)
Focus id ->
( model, Task.attempt Focused (Dom.focus id) )