Merge pull request #1338 from NoRedInk/table-sticky-header

add sticky headers to sortable tables
This commit is contained in:
Brian Hicks 2023-04-04 13:03:21 -05:00 committed by GitHub
commit ace1f19247
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 997 additions and 80 deletions

View File

@ -19,7 +19,7 @@ import Nri.Ui.AnimatedIcon.V1 as AnimatedIcon
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
moduleName : String
@ -88,7 +88,7 @@ example =
]
}
, Heading.h2 [ Heading.plaintext "Example" ]
, Table.view
, Table.view []
[ Table.custom
{ header = text "Rendered"
, view =

View File

@ -26,7 +26,7 @@ import Nri.Ui.ClickableText.V3 as ClickableText
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Spacing.V1 as Spacing
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import Nri.Ui.Text.V6 as Text
import Nri.Ui.UiIcon.V1 as UiIcon
import Task
@ -251,7 +251,7 @@ example =
, Block.labelPosition (Dict.get longId offsets)
]
]
, Table.view
, Table.view []
[ Table.custom
{ header = text "Pattern name & description"
, view = .description >> Markdown.toHtml Nothing >> List.map fromUnstyled >> div []

View File

@ -23,7 +23,7 @@ import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Html.V3 exposing (viewJust)
import Nri.Ui.Svg.V1 as Svg exposing (Svg)
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import Nri.Ui.UiIcon.V1 as UiIcon
@ -173,7 +173,7 @@ example =
[ Heading.h2 [ Heading.plaintext "viewSecondary Example" ]
, viewJust (Tuple.second >> viewSecondaryExample settings.currentRoute) breadCrumbs
]
, Table.view
, Table.view []
[ Table.string
{ header = "Name"
, value = .name

View File

@ -21,7 +21,7 @@ import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
moduleName : String
@ -66,7 +66,7 @@ example =
, Heading.h2 [ Heading.plaintext "Customizable example" ]
, exampleView
, Heading.h2 [ Heading.plaintext "Examples" ]
, Table.view
, Table.view []
[ Table.string
{ header = "State"
, value = .state

View File

@ -13,7 +13,7 @@ import Html.Styled as Html exposing (Html)
import Html.Styled.Attributes exposing (css)
import Nri.Ui.ClickableText.V3 as ClickableText
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import Nri.Ui.Text.V6 as Text
@ -90,7 +90,7 @@ viewFontFailurePatterns =
, Css.textAlign Css.center
]
in
Table.view
Table.view []
[ Table.rowHeader
{ header = Html.text "Example"
, view = Html.text << .example

View File

@ -25,7 +25,7 @@ import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Highlightable.V2 as Highlightable exposing (Highlightable)
import Nri.Ui.Highlighter.V3 as Highlighter
import Nri.Ui.HighlighterTool.V1 as Tool
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import String.Extra
@ -113,7 +113,7 @@ example =
]
, Heading.h2 [ Heading.plaintext "Non-interactive examples" ]
, Heading.h3 [ Heading.plaintext "These are examples of some different ways the highlighter can appear to users." ]
, Table.view
, Table.view []
[ Table.rowHeader
{ header = text "Highlighter."
, view = .viewName >> text

View File

@ -29,7 +29,7 @@ import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Menu.V4 as Menu
import Nri.Ui.Spacing.V1 as Spacing
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import Nri.Ui.TextInput.V7 as TextInput
import Nri.Ui.Tooltip.V3 as Tooltip
import Nri.Ui.UiIcon.V1 as UiIcon
@ -230,7 +230,7 @@ view ellieLinkConfig state =
[ Heading.plaintext "Menu types"
, Heading.css [ Css.margin2 Spacing.verticalSpacerPx Css.zero ]
]
, Table.view
, Table.view []
[ Table.string
{ header = "Menu type"
, value = .menu

View File

@ -33,7 +33,7 @@ import Nri.Ui.MediaQuery.V1 exposing (..)
import Nri.Ui.QuestionBox.V4 as QuestionBox
import Nri.Ui.Spacing.V1 as Spacing
import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import Nri.Ui.Text.V6 as Text
import Nri.Ui.UiIcon.V1 as UiIcon
import Task
@ -238,7 +238,7 @@ While these visions did appear.
]
]
]
, Table.view
, Table.view []
[ Table.custom
{ header = text "Pattern name & description"
, view = .description >> Markdown.toHtml Nothing >> List.map fromUnstyled >> div []

View File

@ -20,7 +20,7 @@ import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.RingGauge.V1 as RingGauge
import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import Round
import SolidColor.Accessibility
@ -103,7 +103,7 @@ example =
|> Svg.withWidth (Css.px 200)
|> Svg.withHeight (Css.px 200)
|> Svg.toHtml
, Table.view
, Table.view []
[ Table.string
{ header = "Color contrast against"
, value = .name

View File

@ -10,16 +10,16 @@ import Category exposing (Category(..))
import Code
import Css exposing (..)
import Debug.Control as Control exposing (Control)
import Debug.Control.Extra as ControlExtra
import Debug.Control.Extra as ControlExtra exposing (values)
import Debug.Control.View as ControlView
import Example exposing (Example)
import Html.Styled as Html exposing (..)
import Html.Styled.Attributes exposing (css)
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.SortableTable.V3 as SortableTable exposing (Column)
import Nri.Ui.SortableTable.V4 as SortableTable exposing (Column)
import Nri.Ui.Svg.V1 as Svg exposing (Svg)
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import Nri.Ui.UiIcon.V1 as UiIcon
@ -30,7 +30,7 @@ moduleName =
version : Int
version =
3
4
{-| -}
@ -74,7 +74,7 @@ example =
|> Svg.withHeight (Css.px 12)
|> Svg.toHtml
in
[ Table.view
[ Table.view []
[ Table.custom
{ header = header "X"
, view = .x >> Html.text
@ -104,10 +104,24 @@ example =
settings =
Control.currentValue model.settings
config =
{ updateMsg = SetSortState
, columns = columns
}
attrs =
List.filterMap identity
[ Just (SortableTable.updateMsg SetSortState)
, Just (SortableTable.state sortState)
, Maybe.map
(\stickiness ->
case stickiness of
Default ->
SortableTable.stickyHeader
Custom customConfig ->
SortableTable.stickyHeaderCustom customConfig
)
settings.stickyHeader
]
isStickyAtAll =
settings.stickyHeader /= Nothing
( dataCode, data ) =
List.unzip dataWithCode
@ -119,15 +133,35 @@ example =
{ sectionName = viewName
, code =
[ moduleName ++ "." ++ viewName
, Code.recordMultiline
[ ( "updateMsg", "SetSortState" )
, ( "columns", Code.listMultiline columnsCode 2 )
]
, Code.listMultiline
(List.filterMap identity
[ Just "SortableTable.updateMsg SetSortState"
, "-- The SortableTable's state should be stored on the model, rather than initialized in the view"
++ "\n "
++ "SortableTable.state (SortableTable.init "
++ Debug.toString model.sortState.column
++ ")"
|> Just
, Maybe.map
(\stickiness ->
case stickiness of
Default ->
"SortableTable.stickyHeader"
Custom stickyConfig ->
"SortableTable.stickyHeaderCustom "
++ Code.recordMultiline
[ ( "topOffset", String.fromFloat stickyConfig.topOffset )
, ( "zIndex", String.fromInt stickyConfig.zIndex )
, ( "pageBackgroundColor", "Css.hex \"" ++ stickyConfig.pageBackgroundColor.value ++ "\"" )
]
2
)
settings.stickyHeader
]
)
1
, Code.newlineWithIndent 1
, Code.commentInline "The SortableTable's state should be stored on the model, rather than initialized in the view"
, Code.newlineWithIndent 1
, Code.withParens ("SortableTable.init " ++ Debug.toString model.sortState.column)
, Code.listMultiline columnsCode 1
, finalArgs
]
|> String.join ""
@ -149,10 +183,19 @@ example =
}
, Heading.h2 [ Heading.plaintext "Example" ]
, if settings.loading then
SortableTable.viewLoading config sortState
SortableTable.viewLoading attrs columns
else
SortableTable.view config sortState data
SortableTable.view attrs
columns
(if isStickyAtAll then
data
|> List.repeat 10
|> List.concat
else
data
)
]
}
@ -274,9 +317,15 @@ type alias Settings =
, customizableColumnWidth : Int
, customizableColumnCellStyles : ( String, List Style )
, loading : Bool
, stickyHeader : Maybe StickyHeader
}
type StickyHeader
= Default
| Custom SortableTable.StickyConfig
controlSettings : Control Settings
controlSettings =
Control.record Settings
@ -295,6 +344,25 @@ controlSettings =
]
)
|> Control.field "Is loading" (Control.bool False)
|> Control.field "Sticky header"
(Control.maybe False
(Control.choice
[ ( "Default", Control.value Default )
, ( "Custom"
, Control.record SortableTable.StickyConfig
|> Control.field "topOffset" (values String.fromFloat [ 0, 10, 50 ])
|> Control.field "zIndex" (values String.fromInt [ 0, 1, 5, 10 ])
|> Control.field "pageBackgroundColor"
(Control.choice
[ ( "white", Control.value Colors.white )
, ( "gray", Control.value Colors.gray92 )
]
)
|> Control.map Custom
)
]
)
)
type ColumnId

View File

@ -21,7 +21,7 @@ import Nri.Ui.Container.V2 as Container
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Spacing.V1 as Spacing
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import Svg.Styled
import Svg.Styled.Attributes
@ -237,7 +237,7 @@ view ellieLinkConfig state =
, Heading.h2 [ Heading.plaintext "Example", Heading.css [ Css.marginTop Spacing.verticalSpacerPx ] ]
, fakePage [ exampleView ]
, Heading.h2 [ Heading.plaintext "Content alignment", Heading.css [ Css.marginTop Spacing.verticalSpacerPx ] ]
, Table.view
, Table.view []
[ Table.string
{ header = "Name"
, value = .name
@ -275,7 +275,7 @@ view ellieLinkConfig state =
, { name = "centeredContentWithCustomWidth", alignment = "Centered", maxWidth = "(customizable)", sidePadding = "0px" }
]
, Heading.h2 [ Heading.plaintext "Constants", Heading.css [ Css.marginTop Spacing.verticalSpacerPx ] ]
, Table.view
, Table.view []
[ Table.string
{ header = "Name"
, value = .name

View File

@ -15,7 +15,7 @@ import Debug.Control.View as ControlView
import Example exposing (Example)
import Nri.Ui.Button.V10 as Button
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Table.V6 as Table exposing (Column)
import Nri.Ui.Table.V7 as Table exposing (Column)
{-| -}
@ -30,7 +30,7 @@ moduleName =
version : Int
version =
6
7
{-| -}
@ -44,7 +44,7 @@ example =
, categories = [ Layout ]
, keyboardSupport = []
, preview =
[ Table.view
[ Table.view []
[ Table.string
{ header = "A"
, value = .a
@ -98,6 +98,7 @@ example =
{ sectionName = moduleName ++ "." ++ viewName
, code =
(moduleName ++ "." ++ viewName)
++ " [] "
++ Code.list columnsCode
++ dataStr
}
@ -111,16 +112,16 @@ example =
, Heading.h2 [ Heading.plaintext "Example" ]
, case ( showHeader, isLoading ) of
( True, False ) ->
Table.view columns data
Table.view [] columns data
( False, False ) ->
Table.viewWithoutHeader columns data
Table.viewWithoutHeader [] columns data
( True, True ) ->
Table.viewLoading columns
Table.viewLoading [] columns
( False, True ) ->
Table.viewLoadingWithoutHeader columns
Table.viewLoadingWithoutHeader [] columns
]
}

View File

@ -25,7 +25,7 @@ import Nri.Ui.ClickableText.V3 as ClickableText
import Nri.Ui.Colors.V1 as Colors
import Nri.Ui.Heading.V3 as Heading
import Nri.Ui.Svg.V1 as Svg
import Nri.Ui.Table.V6 as Table
import Nri.Ui.Table.V7 as Table
import Nri.Ui.Tooltip.V3 as Tooltip
import Nri.Ui.UiIcon.V1 as UiIcon
@ -136,7 +136,7 @@ view : EllieLink.Config -> State -> List (Html Msg)
view ellieLinkConfig model =
[ viewCustomizableExample ellieLinkConfig model.staticExampleSettings
, Heading.h2 [ Heading.plaintext "What type of tooltip should I use?" ]
, Table.view
, Table.view []
[ Table.string
{ header = "Type"
, value = .name

View File

@ -6,5 +6,7 @@ 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
Nri.Ui.SortableTable.V3,upgrade to V4
Nri.Ui.Table.V6,upgrade to V7
Nri.Ui.Tabs.V6,upgrade to V8
Nri.Ui.Tabs.V7,upgrade to V8

1 Nri.Ui.Block.V3 upgrade to V4
6 Nri.Ui.QuestionBox.V2 upgrade to V4
7 Nri.Ui.QuestionBox.V3 upgrade to V4
8 Nri.Ui.Select.V8 upgrade to V9
9 Nri.Ui.SortableTable.V3 upgrade to V4
10 Nri.Ui.Table.V6 upgrade to V7
11 Nri.Ui.Tabs.V6 upgrade to V8
12 Nri.Ui.Tabs.V7 upgrade to V8

View File

@ -70,11 +70,13 @@
"Nri.Ui.Shadows.V1",
"Nri.Ui.SideNav.V4",
"Nri.Ui.SortableTable.V3",
"Nri.Ui.SortableTable.V4",
"Nri.Ui.Spacing.V1",
"Nri.Ui.Sprite.V1",
"Nri.Ui.Svg.V1",
"Nri.Ui.Switch.V2",
"Nri.Ui.Table.V6",
"Nri.Ui.Table.V7",
"Nri.Ui.Tabs.V6",
"Nri.Ui.Tabs.V7",
"Nri.Ui.Tabs.V8",

View File

@ -188,6 +188,9 @@ hint = 'upgrade to V4'
[forbidden."Nri.Ui.SortableTable.V2"]
hint = 'upgrade to V3'
[forbidden."Nri.Ui.SortableTable.V3"]
hint = 'upgrade to V4'
[forbidden."Nri.Ui.Switch.V1"]
hint = 'upgrade to V2'
@ -197,6 +200,9 @@ hint = 'upgrade to V5'
[forbidden."Nri.Ui.Table.V5"]
hint = 'upgrade to V6'
[forbidden."Nri.Ui.Table.V6"]
hint = 'upgrade to V7'
[forbidden."Nri.Ui.Tabs.V6"]
hint = 'upgrade to V8'

View File

@ -32,7 +32,7 @@ import Nri.Ui.Colors.V1
import Nri.Ui.CssVendorPrefix.V1 as CssVendorPrefix
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Svg.V1
import Nri.Ui.Table.V6 as Table exposing (SortDirection(..))
import Nri.Ui.Table.V7 as Table exposing (SortDirection(..))
import Nri.Ui.UiIcon.V1
@ -189,8 +189,7 @@ viewLoading config state =
tableColumns =
List.map (buildTableColumn config.updateMsg state) config.columns
in
Table.viewLoading
tableColumns
Table.viewLoading [] tableColumns
{-| -}
@ -203,7 +202,7 @@ view config state entries =
sorter =
findSorter config.columns state.column
in
Table.view
Table.view []
tableColumns
(List.sortWith (sorter state.sortDirection) entries)

View File

@ -0,0 +1,505 @@
module Nri.Ui.SortableTable.V4 exposing
( Column, Sorter, State
, init, initDescending
, custom, string
, Attribute, updateMsg, state, stickyHeader, stickyHeaderCustom, StickyConfig, view, viewLoading
, invariantSort, simpleSort, combineSorters
)
{-| Changes from V3:
- Change to an HTML-like API
- Allow the table header to be sticky
@docs Column, Sorter, State
@docs init, initDescending
@docs custom, string
@docs Attribute, updateMsg, state, stickyHeader, stickyHeaderCustom, StickyConfig, view, viewLoading
@docs invariantSort, simpleSort, combineSorters
-}
import Accessibility.Styled.Aria as Aria
import Css exposing (..)
import Css.Global
import Html.Styled as Html exposing (Html)
import Html.Styled.Attributes exposing (css)
import Html.Styled.Events
import Nri.Ui.Colors.V1
import Nri.Ui.CssVendorPrefix.V1 as CssVendorPrefix
import Nri.Ui.Fonts.V1 as Fonts
import Nri.Ui.Html.Attributes.V2 exposing (maybe)
import Nri.Ui.Html.V3 exposing (viewJust)
import Nri.Ui.Svg.V1
import Nri.Ui.Table.V7 as Table exposing (SortDirection(..))
import Nri.Ui.UiIcon.V1
{-| -}
type alias Sorter a =
SortDirection -> a -> a -> Order
{-| -}
type Column id entry msg
= Column
{ id : id
, header : Html msg
, view : entry -> Html msg
, sorter : Maybe (Sorter entry)
, width : Int
, cellStyles : entry -> List Style
}
{-| -}
type alias State id =
{ column : id
, sortDirection : SortDirection
}
{-| -}
type alias Config id msg =
{ updateMsg : Maybe (State id -> msg)
, state : Maybe (State id)
, stickyHeader : Maybe StickyConfig
}
defaultConfig : Config id msg
defaultConfig =
{ updateMsg = Nothing
, state = Nothing
, stickyHeader = Nothing
}
{-| How the header will be set up to be sticky.
- `topOffset` controls how far off the top of the viewport the headers will
stick, in pixels. (**Default value:** 0)
- `zIndex` controls where in the stacking context the header will end
up. Useful to prevent elements in rows from appearing over the header.
(**Default value:** 0)
Headers are never sticky on mobile-sized viewports because doing so causes some
accessibility issues with zooming and panning.
-}
type alias StickyConfig =
{ topOffset : Float
, zIndex : Int
, pageBackgroundColor : Css.Color
}
defaultStickyConfig : StickyConfig
defaultStickyConfig =
{ topOffset = 0
, zIndex = 0
, pageBackgroundColor = Nri.Ui.Colors.V1.white
}
stickyConfigStyles : StickyConfig -> List Style
stickyConfigStyles { topOffset, zIndex, pageBackgroundColor } =
[ Css.Global.children
[ Css.Global.thead
[ Css.position Css.sticky
, Css.top (Css.px topOffset)
, Css.zIndex (Css.int zIndex)
, Css.backgroundColor pageBackgroundColor
]
]
]
{-| Customize how the table is rendered, for example by adding sorting or
stickiness.
-}
type Attribute id msg
= Attribute (Config id msg -> Config id msg)
{-| Sort a column. You can get an initial state with `init` or `initDescending`.
If you make this sorting interactive, you should store the state in your model
and provide it to this function instead of recreating it on every update.
-}
state : State id -> Attribute id msg
state state_ =
Attribute (\config -> { config | state = Just state_ })
{-| Add interactivity in sorting columns. When this attribute is provided and
sorting is enabled, columns will be sortable by clicking the headers.
-}
updateMsg : (State id -> msg) -> Attribute id msg
updateMsg updateMsg_ =
Attribute (\config -> { config | updateMsg = Just updateMsg_ })
{-| Make the header sticky (that is, it will stick to the top of the viewport
when it otherwise would have been scrolled off.) You probably will want to set a
background color on the header as well.
-}
stickyHeader : Attribute id msg
stickyHeader =
Attribute (\config -> { config | stickyHeader = Just defaultStickyConfig })
{-| Does the same thing as `stickyHeader`, but with adaptations for your
specific use.
-}
stickyHeaderCustom : StickyConfig -> Attribute id msg
stickyHeaderCustom stickyConfig =
Attribute (\config -> { config | stickyHeader = Just stickyConfig })
{-| -}
init : id -> State id
init initialSort =
{ column = initialSort
, sortDirection = Ascending
}
{-| -}
initDescending : id -> State id
initDescending initialSort =
{ column = initialSort
, sortDirection = Descending
}
{-| -}
string :
{ id : id
, header : String
, value : entry -> String
, width : Int
, cellStyles : entry -> List Style
}
-> Column id entry msg
string { id, header, value, width, cellStyles } =
Column
{ id = id
, header = Html.text header
, view = value >> Html.text
, sorter = Just (simpleSort value)
, width = width
, cellStyles = cellStyles
}
{-| -}
custom :
{ id : id
, header : Html msg
, view : entry -> Html msg
, sorter : Maybe (Sorter entry)
, width : Int
, cellStyles : entry -> List Style
}
-> Column id entry msg
custom config =
Column
{ id = config.id
, header = config.header
, view = config.view
, sorter = config.sorter
, width = config.width
, cellStyles = config.cellStyles
}
{-| Create a sorter function that always orders the entries in the same order.
For example, this is useful when we want to resolve ties and sort the tied
entries by name, no matter of the sort direction set on the table.
-}
invariantSort : (entry -> comparable) -> Sorter entry
invariantSort mapper =
\_ elem1 elem2 ->
compare (mapper elem1) (mapper elem2)
{-| Create a simple sorter function that orders entries by mapping a function
over the collection. It will also reverse it when the sort direction is descending.
-}
simpleSort : (entry -> comparable) -> Sorter entry
simpleSort mapper =
\sortDirection elem1 elem2 ->
let
result =
compare (mapper elem1) (mapper elem2)
in
case sortDirection of
Ascending ->
result
Descending ->
flipOrder result
flipOrder : Order -> Order
flipOrder order =
case order of
LT ->
GT
EQ ->
EQ
GT ->
LT
{-| -}
combineSorters : List (Sorter entry) -> Sorter entry
combineSorters sorters =
\sortDirection elem1 elem2 ->
let
folder =
\sorter acc ->
case acc of
EQ ->
sorter sortDirection elem1 elem2
_ ->
acc
in
List.foldl folder EQ sorters
{-| -}
view : List (Attribute id msg) -> List (Column id entry msg) -> List entry -> Html msg
view attributes columns entries =
let
config =
List.foldl (\(Attribute fn) soFar -> fn soFar) defaultConfig attributes
stickyStyles =
Maybe.map stickyConfigStyles config.stickyHeader
|> Maybe.withDefault []
tableColumns =
List.map (buildTableColumn config.updateMsg config.state) columns
in
case config.state of
Just state_ ->
let
sorter =
findSorter columns state_.column
in
Table.view stickyStyles
tableColumns
(List.sortWith (sorter state_.sortDirection) entries)
Nothing ->
Table.view stickyStyles tableColumns entries
{-| -}
viewLoading : List (Attribute id msg) -> List (Column id entry msg) -> Html msg
viewLoading attributes columns =
let
config =
List.foldl (\(Attribute fn) soFar -> fn soFar) defaultConfig attributes
stickyStyles =
Maybe.map stickyConfigStyles config.stickyHeader
|> Maybe.withDefault []
tableColumns =
List.map (buildTableColumn config.updateMsg config.state) columns
in
Table.viewLoading stickyStyles tableColumns
findSorter : List (Column id entry msg) -> id -> Sorter entry
findSorter columns columnId =
columns
|> listExtraFind (\(Column column) -> column.id == columnId)
|> Maybe.andThen (\(Column column) -> column.sorter)
|> Maybe.withDefault identitySorter
{-| Taken from <https://github.com/elm-community/list-extra/blob/8.2.0/src/List/Extra.elm#L556>
-}
listExtraFind : (a -> Bool) -> List a -> Maybe a
listExtraFind predicate list =
case list of
[] ->
Nothing
first :: rest ->
if predicate first then
Just first
else
listExtraFind predicate rest
identitySorter : Sorter a
identitySorter =
\_ _ _ ->
EQ
buildTableColumn : Maybe (State id -> msg) -> Maybe (State id) -> Column id entry msg -> Table.Column entry msg
buildTableColumn maybeUpdateMsg maybeState (Column column) =
Table.custom
{ header =
case maybeState of
Just state_ ->
viewSortHeader (column.sorter /= Nothing) column.header maybeUpdateMsg state_ column.id
Nothing ->
Debug.todo "non-sorted header"
, view = column.view
, width = Css.px (toFloat column.width)
, cellStyles = column.cellStyles
, sort =
Maybe.andThen
(\state_ ->
if state_.column == column.id then
Just state_.sortDirection
else
Nothing
)
maybeState
}
viewSortHeader : Bool -> Html msg -> Maybe (State id -> msg) -> State id -> id -> Html msg
viewSortHeader isSortable header maybeUpdateMsg state_ id =
let
nextState =
nextTableState state_ id
in
if isSortable then
Html.button
[ css
[ Css.displayFlex
, Css.alignItems Css.center
, Css.justifyContent Css.spaceBetween
, CssVendorPrefix.property "user-select" "none"
, if state_.column == id then
fontWeight bold
else
fontWeight normal
, cursor pointer
-- make this look less "buttony"
, Css.border Css.zero
, Css.backgroundColor Css.transparent
, Css.width (Css.pct 100)
, Css.height (Css.pct 100)
, Css.margin Css.zero
, Css.padding Css.zero
, Fonts.baseFont
, Css.fontSize (Css.em 1)
]
, maybe (\updateMsg_ -> Html.Styled.Events.onClick (updateMsg_ nextState)) maybeUpdateMsg
-- screen readers should know what clicking this button will do
, Aria.roleDescription "sort button"
]
[ Html.div [] [ header ]
, viewJust (\_ -> viewSortButton state_ id) maybeUpdateMsg
]
else
Html.div
[ css [ fontWeight normal ]
]
[ header ]
viewSortButton : State id -> id -> Html msg
viewSortButton state_ id =
let
arrows upHighlighted downHighlighted =
Html.div
[ css
[ Css.displayFlex
, Css.flexDirection Css.column
, Css.alignItems Css.center
, Css.justifyContent Css.center
]
]
[ sortArrow Up upHighlighted
, sortArrow Down downHighlighted
]
buttonContent =
case ( state_.column == id, state_.sortDirection ) of
( True, Ascending ) ->
arrows True False
( True, Descending ) ->
arrows False True
( False, _ ) ->
arrows False False
in
Html.div [ css [ padding (px 2) ] ] [ buttonContent ]
nextTableState : State id -> id -> State id
nextTableState state_ id =
if state_.column == id then
{ column = id
, sortDirection = flipSortDirection state_.sortDirection
}
else
{ column = id
, sortDirection = Ascending
}
flipSortDirection : SortDirection -> SortDirection
flipSortDirection order =
case order of
Ascending ->
Descending
Descending ->
Ascending
type Direction
= Up
| Down
sortArrow : Direction -> Bool -> Html msg
sortArrow direction active =
let
arrow =
case direction of
Up ->
Nri.Ui.UiIcon.V1.sortArrow
Down ->
Nri.Ui.UiIcon.V1.sortArrowDown
color =
if active then
Nri.Ui.Colors.V1.azure
else
Nri.Ui.Colors.V1.gray75
in
arrow
|> Nri.Ui.Svg.V1.withHeight (px 6)
|> Nri.Ui.Svg.V1.withWidth (px 8)
|> Nri.Ui.Svg.V1.withColor color
|> Nri.Ui.Svg.V1.withCss
[ displayFlex
, margin2 (px 1) zero
]
|> Nri.Ui.Svg.V1.toHtml

336
src/Nri/Ui/Table/V7.elm Normal file
View File

@ -0,0 +1,336 @@
module Nri.Ui.Table.V7 exposing
( Column, SortDirection(..), custom, string, rowHeader
, view, viewWithoutHeader
, viewLoading, viewLoadingWithoutHeader
)
{-| Upgrading from V6:
- If you don't have extra styles for the table, add an empty list to calls
to `view`.
- Consider moving to `SortableTable`, as in V4 a non-interactive table renders
just the same but has additional flexibility in the API and will make future
upgrades easier.
@docs Column, SortDirection, custom, string, rowHeader
@docs view, viewWithoutHeader
@docs viewLoading, viewLoadingWithoutHeader
-}
import Accessibility.Styled.Style as Style
import Css exposing (..)
import Css.Animations
import Html.Styled as Html exposing (..)
import Html.Styled.Attributes as Attributes exposing (css)
import Nri.Ui.Colors.V1 exposing (..)
import Nri.Ui.Fonts.V1 exposing (baseFont)
{-| Closed representation of how to render the header and cells of a column
in the table
-}
type Column data msg
= Column (Html msg) (data -> Html msg) Style (data -> List Style) (Maybe SortDirection) CellType
{-| Which direction is a table column sorted? Only set these on columns that
actually have an explicit sort!
-}
type SortDirection
= Ascending
| Descending
{-| Is this cell a data cell or header cell?
-}
type CellType
= RowHeaderCell
| DataCell
cell : CellType -> List (Attribute msg) -> List (Html msg) -> Html msg
cell cellType attrs =
case cellType of
RowHeaderCell ->
th (Attributes.scope "row" :: attrs)
DataCell ->
td attrs
{-| A column that renders some aspect of a value as text
-}
string :
{ header : String
, value : data -> String
, width : LengthOrAuto compatible
, cellStyles : data -> List Style
, sort : Maybe SortDirection
}
-> Column data msg
string { header, value, width, cellStyles, sort } =
Column (Html.text header) (value >> Html.text) (Css.width width) cellStyles sort DataCell
{-| A column that renders however you want it to
-}
custom :
{ header : Html msg
, view : data -> Html msg
, width : LengthOrAuto compatible
, cellStyles : data -> List Style
, sort : Maybe SortDirection
}
-> Column data msg
custom options =
Column options.header options.view (Css.width options.width) options.cellStyles options.sort DataCell
{-| A column whose cells are row headers
-}
rowHeader :
{ header : Html msg
, view : data -> Html msg
, width : LengthOrAuto compatible
, cellStyles : data -> List Style
, sort : Maybe SortDirection
}
-> Column data msg
rowHeader options =
Column options.header options.view (Css.width options.width) options.cellStyles options.sort RowHeaderCell
-- VIEW
{-| Displays a table of data without a header row
-}
viewWithoutHeader : List Style -> List (Column data msg) -> List data -> Html msg
viewWithoutHeader additionalStyles columns =
tableWithoutHeader additionalStyles columns (viewRow columns)
{-| Displays a table of data based on the provided column definitions
-}
view : List Style -> List (Column data msg) -> List data -> Html msg
view additionalStyles columns =
tableWithHeader additionalStyles columns (viewRow columns)
viewRow : List (Column data msg) -> data -> Html msg
viewRow columns data =
tr
[ css rowStyles ]
(List.map (viewColumn data) columns)
viewColumn : data -> Column data msg -> Html msg
viewColumn data (Column _ renderer width cellStyles _ cellType) =
cell cellType
[ css ([ width, verticalAlign middle ] ++ cellStyles data)
]
[ renderer data ]
-- VIEW LOADING
{-| Display a table with the given columns but instead of data, show blocked
out text with an interesting animation. This view lets the user know that
data is on its way and what it will look like when it arrives.
-}
viewLoading : List Style -> List (Column data msg) -> Html msg
viewLoading additionalStyles columns =
tableWithHeader (loadingTableStyles ++ additionalStyles) columns (viewLoadingRow columns) (List.range 0 8)
{-| Display the loading table without a header row
-}
viewLoadingWithoutHeader : List Style -> List (Column data msg) -> Html msg
viewLoadingWithoutHeader additionalStyles columns =
tableWithoutHeader (loadingTableStyles ++ additionalStyles) columns (viewLoadingRow columns) (List.range 0 8)
viewLoadingRow : List (Column data msg) -> Int -> Html msg
viewLoadingRow columns index =
tr
[ css rowStyles ]
(List.indexedMap (viewLoadingColumn index) columns)
viewLoadingColumn : Int -> Int -> Column data msg -> Html msg
viewLoadingColumn rowIndex colIndex (Column _ _ width _ _ cellType) =
cell cellType
[ css (stylesLoadingColumn rowIndex colIndex width ++ [ verticalAlign middle ] ++ loadingCellStyles)
]
[ span [ css loadingContentStyles ] [] ]
stylesLoadingColumn : Int -> Int -> Style -> List Style
stylesLoadingColumn rowIndex colIndex width =
[ width
, property "animation-delay" (String.fromFloat (toFloat (rowIndex + colIndex) * 0.1) ++ "s")
]
-- HELP
tableWithoutHeader : List Style -> List (Column data msg) -> (a -> Html msg) -> List a -> Html msg
tableWithoutHeader styles columns toRow data =
table styles
[ thead [] [ tr Style.invisible (List.map tableColHeader columns) ]
, tableBody toRow data
]
tableWithHeader : List Style -> List (Column data msg) -> (a -> Html msg) -> List a -> Html msg
tableWithHeader styles columns toRow data =
table styles
[ tableHeader columns
, tableBody toRow data
]
table : List Style -> List (Html msg) -> Html msg
table styles =
Html.table [ css (styles ++ tableStyles) ]
tableHeader : List (Column data msg) -> Html msg
tableHeader columns =
thead []
[ tr [ css headersStyles ]
(List.map tableColHeader columns)
]
tableColHeader : Column data msg -> Html msg
tableColHeader (Column header _ width _ sort _) =
th
[ Attributes.scope "col"
, css (width :: headerStyles)
, Attributes.attribute "aria-sort" <|
case sort of
Nothing ->
"none"
Just Ascending ->
"ascending"
Just Descending ->
"descending"
]
[ header ]
tableBody : (a -> Html msg) -> List a -> Html msg
tableBody toRow items =
tbody [] (List.map toRow items)
-- STYLES
headersStyles : List Style
headersStyles =
[ -- We use a inset box shadown for a bottom border instead of an actual
-- border because with our use of `border-collapse: collapse`, the bottom
-- gray border sticks to the table instead of traveling with the header
-- when the header has `position: sticky` applied.
boxShadow4 inset (px 0) (px -3) gray75
, height (px 45)
, fontSize (px 15)
]
headerStyles : List Style
headerStyles =
[ padding4 (px 11) (px 12) (px 14) (px 12)
, textAlign left
, fontWeight bold
]
rowStyles : List Style
rowStyles =
[ height (px 45)
, fontSize (px 14)
, color gray20
, pseudoClass "nth-child(odd)"
[ backgroundColor gray96 ]
]
loadingContentStyles : List Style
loadingContentStyles =
[ width (pct 100)
, display inlineBlock
, height (Css.em 1)
, borderRadius (Css.em 1)
, backgroundColor gray75
]
loadingCellStyles : List Style
loadingCellStyles =
[ batch flashAnimation
, padding2 (px 14) (px 10)
]
loadingTableStyles : List Style
loadingTableStyles =
fadeInAnimation
tableStyles : List Style
tableStyles =
[ borderCollapse collapse
, baseFont
, Css.width (Css.pct 100)
]
flash : Css.Animations.Keyframes {}
flash =
Css.Animations.keyframes
[ ( 0, [ Css.Animations.opacity (Css.num 0.6) ] )
, ( 50, [ Css.Animations.opacity (Css.num 0.2) ] )
, ( 100, [ Css.Animations.opacity (Css.num 0.6) ] )
]
fadeIn : Css.Animations.Keyframes {}
fadeIn =
Css.Animations.keyframes
[ ( 0, [ Css.Animations.opacity (Css.num 0) ] )
, ( 100, [ Css.Animations.opacity (Css.num 1) ] )
]
flashAnimation : List Css.Style
flashAnimation =
[ animationName flash
, property "animation-duration" "2s"
, property "animation-iteration-count" "infinite"
, opacity (num 0.6)
]
fadeInAnimation : List Css.Style
fadeInAnimation =
[ animationName fadeIn
, property "animation-duration" "0.4s"
, property "animation-delay" "0.2s"
, property "animation-fill-mode" "forwards"
, animationIterationCount (int 1)
, opacity (num 0)
]

View File

@ -2,7 +2,8 @@ module Spec.Nri.Ui.SortableTable exposing (spec)
import Expect
import Html.Styled
import Nri.Ui.SortableTable.V3 as SortableTable
import Nri.Ui.SortableTable.V4 as SortableTable
import Nri.Ui.Table.V7 exposing (SortDirection)
import Test exposing (..)
import Test.Html.Query as Query
import Test.Html.Selector as Selector
@ -19,30 +20,23 @@ type alias Person =
}
type Msg
= NoOp
config : SortableTable.Config Column Person Msg
config =
{ updateMsg = \_ -> NoOp
, columns =
[ SortableTable.string
{ id = FirstName
, header = "First name"
, value = .firstName
, width = 125
, cellStyles = \_ -> []
}
, SortableTable.string
{ id = LastName
, header = "Last name"
, value = .lastName
, width = 125
, cellStyles = \_ -> []
}
]
}
columns : List (SortableTable.Column Column Person msg)
columns =
[ SortableTable.string
{ id = FirstName
, header = "First name"
, value = .firstName
, width = 125
, cellStyles = \_ -> []
}
, SortableTable.string
{ id = LastName
, header = "Last name"
, value = .lastName
, width = 125
, cellStyles = \_ -> []
}
]
entries : List Person
@ -53,9 +47,9 @@ entries =
]
tableView : SortableTable.State Column -> Query.Single Msg
tableView : SortableTable.State Column -> Query.Single msg
tableView sortState =
SortableTable.view config sortState entries
SortableTable.view [ SortableTable.state sortState ] columns entries
|> Html.Styled.toUnstyled
|> Query.fromHtml
@ -70,10 +64,12 @@ sortByDescending field =
SortableTable.initDescending field
ascending : SortDirection
ascending =
sortBy FirstName |> .sortDirection
descending : SortDirection
descending =
sortByDescending FirstName |> .sortDirection

View File

@ -66,11 +66,13 @@
"Nri.Ui.Shadows.V1",
"Nri.Ui.SideNav.V4",
"Nri.Ui.SortableTable.V3",
"Nri.Ui.SortableTable.V4",
"Nri.Ui.Spacing.V1",
"Nri.Ui.Sprite.V1",
"Nri.Ui.Svg.V1",
"Nri.Ui.Switch.V2",
"Nri.Ui.Table.V6",
"Nri.Ui.Table.V7",
"Nri.Ui.Tabs.V6",
"Nri.Ui.Tabs.V7",
"Nri.Ui.Tabs.V8",