Merge pull request #1578 from NoRedInk/growth/upward-accordion

Upward opening accordions
This commit is contained in:
Mandla 2023-12-12 14:25:38 -08:00 committed by GitHub
commit 36001ad618
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 452 additions and 10 deletions

View File

@ -23,7 +23,7 @@ import EllieLink
import Example exposing (Example)
import Html.Styled.Attributes as Attributes exposing (css, src)
import KeyboardSupport exposing (Key(..))
import Nri.Ui.Accordion.V3 as Accordion exposing (AccordionEntry(..))
import Nri.Ui.Accordion.V4 as Accordion exposing (AccordionEntry(..))
import Nri.Ui.Colors.Extra as ColorsExtra
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.FocusRing.V1 as FocusRing
@ -43,7 +43,7 @@ moduleName =
version : Int
version =
3
4
{-| -}
@ -120,6 +120,7 @@ view ellieLinkConfig model =
[ ( "caret", Tuple.first settings.icon )
, ( "content", Code.anonymousFunction "()" (Tuple.first settings.content) )
, ( "entryClass", Code.string "customizable-example" )
, ( "expansionDirection", Tuple.first settings.expansionDirection )
, ( "headerContent", Tuple.first settings.headerContent )
, ( "headerId", Code.string "customizable-example-header" )
, ( "headerLevel", Code.fromModule moduleName "H3" )
@ -163,6 +164,7 @@ view ellieLinkConfig model =
{ caret = Tuple.second settings_.icon
, content = \() -> Tuple.second settings_.content
, entryClass = "customizable-example"
, expansionDirection = Tuple.second settings_.expansionDirection
, headerContent = Tuple.second settings_.headerContent
, headerId = "customizable-example-header"
, headerLevel = Accordion.H3
@ -174,6 +176,7 @@ view ellieLinkConfig model =
{ caret = Accordion.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"
, expansionDirection = Accordion.Downwards
, headerContent = Html.text "Apples (has children)"
, headerId = "accordion-entry__1"
, headerLevel = Accordion.H3
@ -193,6 +196,7 @@ view ellieLinkConfig model =
[ Html.text "Wikipedia article on Gala Apples" ]
]
, entryClass = "accordion-example-child"
, expansionDirection = Accordion.Downwards
, headerContent = Html.text "Gala"
, headerId = "accordion-entry__11"
, headerLevel = Accordion.H4
@ -213,6 +217,7 @@ view ellieLinkConfig model =
[ Html.text "Wikipedia article on Granny Smith Apples" ]
]
, entryClass = "accordion-example-child"
, expansionDirection = Accordion.Downwards
, headerContent = Html.text "Granny Smith"
, headerId = "accordion-entry__12"
, headerLevel = Accordion.H4
@ -233,6 +238,7 @@ view ellieLinkConfig model =
[ Html.text "Wikipedia article on Fuji Apples" ]
]
, entryClass = "accordion-example-child"
, expansionDirection = Accordion.Downwards
, headerContent = Html.text "Fuji"
, headerId = "accordion-entry__13"
, headerLevel = Accordion.H4
@ -242,10 +248,11 @@ view ellieLinkConfig model =
[]
]
, AccordionEntry
{ caret = Accordion.defaultCaret
{ caret = Accordion.upwardCaret
, content = \_ -> Html.text "🍊 I don't know anything about oranges! Except: YUM! 🍊"
, entryClass = "accordion-example"
, headerContent = Html.text "Oranges"
, expansionDirection = Accordion.Upwards
, headerContent = Html.text "Oranges (upward accordion)"
, headerId = "accordion-entry__2"
, headerLevel = Accordion.H3
, isExpanded = Set.member 2 model.expanded
@ -277,6 +284,7 @@ view ellieLinkConfig model =
[ Html.img "Wild Apple" [ src "https://upload.wikimedia.org/wikipedia/commons/9/92/95apple.jpeg" ] ]
]
, entryClass = "fixed-positioning-accordion-example"
, expansionDirection = Accordion.Downwards
, headerContent = Html.text "Advanced Example: Expand & Scroll!"
, headerId = "accordion-entry__6"
, headerLevel = Accordion.H3
@ -348,6 +356,7 @@ type alias State =
type alias Settings =
{ icon : ( String, Bool -> Html Msg )
, expansionDirection : ( String, Accordion.ExpansionDirection )
, headerContent : ( String, Html Msg )
, content : ( String, Html Msg )
}
@ -357,6 +366,7 @@ initSettings : Control Settings
initSettings =
Control.record Settings
|> Control.field "icon" controlIcon
|> Control.field "expansionDirection" controlExpansionDirection
|> Control.field "headerContent" controlHeaderContent
|> Control.field "content" controlContent
@ -367,6 +377,9 @@ controlIcon =
[ ( "defaultCaret"
, Control.value ( "Accordion.defaultCaret", Accordion.defaultCaret )
)
, ( "upwardCaret"
, Control.value ( "Accordion.upwardCaret", Accordion.upwardCaret )
)
, ( "none", Control.value ( "\\_ -> text \"\"", \_ -> Html.text "" ) )
, ( "UiIcon"
, Control.map
@ -403,6 +416,18 @@ controlHeaderContent =
(Control.string "Berries")
controlExpansionDirection : Control ( String, Accordion.ExpansionDirection )
controlExpansionDirection =
Control.choice
[ ( "Downwards"
, Control.value ( "Accordion.Downwards", Accordion.Downwards )
)
, ( "Upwards"
, Control.value ( "Accordion.Upwards", Accordion.Upwards )
)
]
controlContent : Control ( String, Html Msg )
controlContent =
Control.map

View File

@ -13,7 +13,7 @@ import Example exposing (Example)
import Html.Styled as Html exposing (..)
import Html.Styled.Attributes exposing (css, href)
import Markdown
import Nri.Ui.Accordion.V3 as Accordion exposing (AccordionEntry(..))
import Nri.Ui.Accordion.V4 as Accordion exposing (AccordionEntry(..))
import Nri.Ui.Button.V10 as Button
import Nri.Ui.Checkbox.V7 as Checkbox
import Nri.Ui.ClickableSvg.V2 as ClickableSvg
@ -248,6 +248,7 @@ NOTE: use `boxShadows` instead if your focusable element:
{ caret = Accordion.defaultCaret
, content = \() -> text "Content"
, entryClass = "accordion-focus-ring"
, expansionDirection = Accordion.Downwards
, headerContent = text "Accordion"
, headerId = "accordion-focus-ring-example-header"
, headerLevel = Accordion.H3

View File

@ -1,3 +1,4 @@
Nri.Ui.Accordion.V3,upgrade to V4
Nri.Ui.Block.V4,upgrade to V6
Nri.Ui.Block.V5,upgrade to V6
Nri.Ui.Carousel.V1,upgrade to V2

1 Nri.Ui.Block.V4 Nri.Ui.Accordion.V3 upgrade to V6 upgrade to V4
1 Nri.Ui.Accordion.V3 upgrade to V4
2 Nri.Ui.Block.V4 Nri.Ui.Block.V4 upgrade to V6 upgrade to V6
3 Nri.Ui.Block.V5 Nri.Ui.Block.V5 upgrade to V6 upgrade to V6
4 Nri.Ui.Carousel.V1 Nri.Ui.Carousel.V1 upgrade to V2 upgrade to V2

View File

@ -10,6 +10,7 @@
"Nri.Test.MouseHelpers.V1",
"Nri.Ui",
"Nri.Ui.Accordion.V3",
"Nri.Ui.Accordion.V4",
"Nri.Ui.AnimatedIcon.V1",
"Nri.Ui.AssignmentIcon.V2",
"Nri.Ui.Balloon.V2",

View File

@ -38,6 +38,9 @@ usages = ['component-catalog-app/../src/Nri/Ui/Button/V8.elm']
[forbidden."Nri.Ui.Accordion.V1"]
hint = 'upgrade to V3'
[forbidden."Nri.Ui.Accordion.V3"]
hint = 'upgrade to V4'
[forbidden."Nri.Ui.Balloon.V1"]
hint = 'upgrade to V2'

393
src/Nri/Ui/Accordion/V4.elm Normal file
View File

@ -0,0 +1,393 @@
module Nri.Ui.Accordion.V4 exposing
( view, HeaderLevel(..)
, AccordionEntry(..), Entry
, StyleOptions, styleAccordion
, defaultCaret
, ExpansionDirection(..), upwardCaret
)
{-|
## Changes from V3
- Enables accordion expansion either upwards or downwards
- Added animated caret for upward accordions
## Example
view : Model -> Html Msg
view model =
div []
[ Accordion.view
{ entries =
[ AccordionEntry
{ caret = Accordion.updwardCaret
, content = \() -> text "Accordion Content"
, entryClass = "a-class-distinguishing-this-accordion-from-others-on-the-page"
, expansionDirection = Accordion.Upwards
, 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
@docs defaultCaret
@docs ExpansionDirection, upwardCaret
-}
import Accessibility.Styled exposing (Html, button, div, section)
import Accessibility.Styled.Aria as Aria
import Accessibility.Styled.Key as Key
import Css exposing (..)
import Css.Global
import Html.Styled.Attributes as Attributes
import Html.Styled.Events exposing (onClick)
import Html.Styled.Keyed
import Nri.Ui.AnimatedIcon.V1 as AnimatedIcon
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.FocusLoop.V1 as FocusLoop
import Nri.Ui.FocusRing.V1 as FocusRing
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Html.Attributes.V2 as AttributesExtra
import Nri.Ui.Svg.V1 as Svg
{-| -}
defaultCaret : Bool -> Html msg
defaultCaret isOpen =
AnimatedIcon.arrowRightDown isOpen
|> Svg.withColor Colors.azure
|> Svg.withWidth (Css.px 17)
|> Svg.withHeight (Css.px 17)
|> Svg.withCss [ Css.marginRight (Css.px 8) ]
|> Svg.toHtml
{-| Animated caret used for accordions that expand upwards.
-}
upwardCaret : Bool -> Html msg
upwardCaret isOpen =
AnimatedIcon.arrowRightUp isOpen
|> Svg.withColor Colors.azure
|> Svg.withWidth (Css.px 17)
|> Svg.withHeight (Css.px 17)
|> Svg.withCss [ Css.marginRight (Css.px 8) ]
|> Svg.toHtml
{-| -}
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
, Css.pseudoClass "focus-visible" [ FocusRing.insetBoxShadows [] ]
-- 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
, Css.Global.class accordionExpandsUpwardsClass
[ Css.displayFlex, Css.flexDirection Css.columnReverse ]
]
accordionClass : String
accordionClass =
"accordion-v4"
accordionHeaderClass : String
accordionHeaderClass =
"accordion-v4-header"
accordionEntryHeaderClass : String
accordionEntryHeaderClass =
"accordion-v4-entry-header"
accordionEntryHeaderExpandedClass : String
accordionEntryHeaderExpandedClass =
"accordion-v4-entry-header-expanded"
accordionEntryHeaderCollapsedClass : String
accordionEntryHeaderCollapsedClass =
"accordion-v4-entry-header-collapsed"
accordionEntryClass : String
accordionEntryClass =
"accordion-v4-entry"
accordionEntryExpandedClass : String
accordionEntryExpandedClass =
"accordion-v4-entry-state-expanded"
accordionEntryCollapsedClass : String
accordionEntryCollapsedClass =
"accordion-v4-entry-state-collapsed"
accordionEntryPanelClass : String
accordionEntryPanelClass =
"accordion-v4-entry-panel"
accordionExpandsUpwardsClass : String
accordionExpandsUpwardsClass =
"accordion-v4-expands-upwards"
{-| Determines in which direction the accordion expands.
-}
type ExpansionDirection
= Upwards
| Downwards
{-| 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
, expansionDirection : ExpansionDirection
, 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 } =
div [ Attributes.class accordionClass ]
[ Html.Styled.Keyed.node "div"
[]
(FocusLoop.addEvents
{ focus = \(AccordionEntry { headerId } _) -> focus headerId
, leftRight = False
, upDown = True
}
entries
|> List.map
(\( AccordionEntry entry_ children, upDownEvents ) ->
( "keyed-section__" ++ entry_.headerId
, viewEntry focus
{ upDownEvents = upDownEvents
, right = Maybe.map getHeaderId (List.head children)
, left = leftId
}
entry_
children
)
)
)
]
viewEntry :
(String -> msg)
->
{ upDownEvents : List (Key.Event msg)
, right : Maybe String
, left : Maybe String
}
-> Entry msg
-> List (AccordionEntry msg)
-> Html msg
viewEntry focus arrows ({ headerId, headerLevel, caret, headerContent, entryClass, expansionDirection, content, isExpanded } as config) children =
let
panelId =
"accordion-panel__" ++ headerId
contents =
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
[]
)
in
div
[ Attributes.classList
[ ( accordionEntryClass, True )
, ( entryClass, True )
, ( accordionEntryExpandedClass, isExpanded )
, ( accordionEntryCollapsedClass, not isExpanded )
, ( accordionExpandsUpwardsClass, expansionDirection == Upwards )
]
]
[ header headerLevel <|
button
[ Attributes.id headerId
, Attributes.classList
[ ( accordionEntryHeaderClass, True )
, ( entryClass, True )
, ( accordionEntryHeaderExpandedClass, isExpanded )
, ( accordionEntryHeaderCollapsedClass, not isExpanded )
, ( FocusRing.customClass, True )
]
, Aria.disabled (config.toggle == Nothing)
, Aria.expanded isExpanded
, Aria.controls [ panelId ]
, config.toggle
|> Maybe.map (\toggle -> onClick (toggle (not isExpanded)))
|> Maybe.withDefault AttributesExtra.none
, Key.onKeyDownPreventDefault
(arrows.upDownEvents
++ List.filterMap identity
[ Maybe.map (\id -> Key.right (focus id)) arrows.right
, Maybe.map (\id -> Key.left (focus id)) arrows.left
]
)
]
[ caret isExpanded
, headerContent
]
, contents
]

View File

@ -1,8 +1,8 @@
module Nri.Ui.AnimatedIcon.V1 exposing (mobileOpenClose, arrowRightDown, arrowDownUp)
module Nri.Ui.AnimatedIcon.V1 exposing (mobileOpenClose, arrowRightDown, arrowRightUp, arrowDownUp)
{-|
@docs mobileOpenClose, arrowRightDown, arrowDownUp
@docs mobileOpenClose, arrowRightDown, arrowRightUp, arrowDownUp
-}
@ -74,6 +74,21 @@ arrowRightDown isOpen =
squareArrowLeft
{-| An arrow that animates between pointing right and pointing up. Typically used for disclosures and accordions.
-}
arrowRightUp : Bool -> Nri.Ui.Svg.V1.Svg
arrowRightUp isOpen =
Nri.Ui.Svg.V1.withCss
[ Css.property "transition" "transform 0.1s"
, if isOpen then
Css.transform (Css.rotate (Css.deg 90))
else
Css.transform (Css.rotate (Css.deg 180))
]
squareArrowLeft
{-| An arrow that animates between pointing down and pointing up. Typically used as a fly-out menu indicator.
-}
arrowDownUp : Bool -> Nri.Ui.Svg.V1.Svg

View File

@ -3,7 +3,7 @@ module Spec.Nri.Ui.Accordion exposing (spec)
import Accessibility.Aria as Aria
import Browser.Dom as Dom
import Html.Styled as Html exposing (..)
import Nri.Ui.Accordion.V3 as Accordion
import Nri.Ui.Accordion.V4 as Accordion
import ProgramTest exposing (..)
import Set exposing (Set)
import Task
@ -14,7 +14,7 @@ import Test.Html.Selector as Selector
spec : Test
spec =
describe "Nri.Ui.Accordion.V3"
describe "Nri.Ui.Accordion.V4"
[ describe "panel rendering" panelRenderingTests
, describe "aria attributes" ariaAttributesTests
]
@ -157,6 +157,7 @@ view model =
{ caret = Accordion.defaultCaret
, content = \() -> text "Content 1"
, entryClass = ""
, expansionDirection = Accordion.Downwards
, headerContent = text "Header 1"
, headerId = "header-1"
, headerLevel = Accordion.H4
@ -165,9 +166,10 @@ view model =
}
[]
, Accordion.AccordionEntry
{ caret = Accordion.defaultCaret
{ caret = Accordion.upwardCaret
, content = \() -> text "Content 2"
, entryClass = ""
, expansionDirection = Accordion.Upwards
, headerContent = text "Header 2"
, headerId = "header-2"
, headerLevel = Accordion.H4

View File

@ -6,6 +6,7 @@
"Nri.Test.MouseHelpers.V1",
"Nri.Ui",
"Nri.Ui.Accordion.V3",
"Nri.Ui.Accordion.V4",
"Nri.Ui.AnimatedIcon.V1",
"Nri.Ui.AssignmentIcon.V2",
"Nri.Ui.Balloon.V2",