diff --git a/ui/src/Layout.elm b/ui/src/Layout.elm index 18f1d6f..cf674a9 100644 --- a/ui/src/Layout.elm +++ b/ui/src/Layout.elm @@ -1,4 +1,4 @@ -module Layout exposing (Page(..), frame) +module Layout exposing (Model, Msg, Page(..), frame, init, reset, update) import Data.Session as Session exposing (Session) import Html exposing (..) @@ -29,56 +29,124 @@ type Page | Status -type alias FrameControls msg = - { viewMailbox : String -> msg - , mailboxOnInput : String -> msg - , mailboxValue : String - , recentOptions : List String - , recentActive : String - , clearFlash : msg - , showMenu : Bool - , toggleMenu : msg +type alias Model msg = + { mapMsg : Msg -> msg + , menuVisible : Bool + , recentVisible : Bool + , mailboxName : String } -frame : FrameControls msg -> Session -> Page -> Maybe (Html msg) -> List (Html msg) -> Html msg -frame controls session activePage modal content = +init : (Msg -> msg) -> Model msg +init mapMsg = + { mapMsg = mapMsg + , menuVisible = False + , recentVisible = False + , mailboxName = "" + } + + +{-| Resets layout state, used when navigating to a new page. +-} +reset : Model msg -> Model msg +reset model = + { model + | menuVisible = False + , recentVisible = False + , mailboxName = "" + } + + +type Msg + = ClearFlash + | OnMailboxNameInput String + | OpenMailbox + | ShowRecent Bool + | ToggleMenu + + +update : Msg -> Model msg -> Session -> ( Model msg, Session, Cmd msg ) +update msg model session = + case msg of + ClearFlash -> + ( model + , Session.clearFlash session + , Cmd.none + ) + + OnMailboxNameInput name -> + ( { model | mailboxName = name } + , session + , Cmd.none + ) + + OpenMailbox -> + ( model + , session + , Route.pushUrl session.key (Route.Mailbox model.mailboxName) + ) + + ShowRecent visible -> + ( { model | recentVisible = visible } + , session + , Cmd.none + ) + + ToggleMenu -> + ( { model | menuVisible = not model.menuVisible } + , session + , Cmd.none + ) + + +type alias State msg = + { model : Model msg + , session : Session + , activePage : Page + , activeMailbox : String + , modal : Maybe (Html msg) + , content : List (Html msg) + } + + +frame : State msg -> Html msg +frame { model, session, activePage, activeMailbox, modal, content } = div [ class "app" ] [ header [] [ nav [ class "navbar" ] - [ span [ class "navbar-toggle", Events.onClick controls.toggleMenu ] + [ button [ class "navbar-toggle", Events.onClick (ToggleMenu |> model.mapMsg) ] [ i [ class "fas fa-bars" ] [] ] , span [ class "navbar-brand" ] [ a [ Route.href Route.Home ] [ text "@ inbucket" ] ] - , ul [ classList [ ( "main-nav", True ), ( "active", controls.showMenu ) ] ] - [ li [ class "navbar-mailbox" ] - [ form [ Events.onSubmit (controls.viewMailbox controls.mailboxValue) ] - [ input - [ type_ "text" - , placeholder "mailbox" - , value controls.mailboxValue - , Events.onInput controls.mailboxOnInput - ] - [] - ] - ] - , if session.config.monitorVisible then + , ul [ class "main-nav", classList [ ( "active", model.menuVisible ) ] ] + [ if session.config.monitorVisible then navbarLink Monitor Route.Monitor [ text "Monitor" ] activePage else text "" , navbarLink Status Route.Status [ text "Status" ] activePage - , navbarRecent activePage controls + , navbarRecent activePage activeMailbox model session + , li [ class "navbar-mailbox" ] + [ form [ Events.onSubmit (OpenMailbox |> model.mapMsg) ] + [ input + [ type_ "text" + , placeholder "mailbox" + , value model.mailboxName + , Events.onInput (OnMailboxNameInput >> model.mapMsg) + ] + [] + ] + ] ] ] ] , div [ class "navbar-bg" ] [ text "" ] , frameModal modal - , div [ class "page" ] ([ errorFlash controls session.flash ] ++ content) + , div [ class "page" ] ([ errorFlash model session.flash ] ++ content) , footer [] [ div [ class "footer" ] [ externalLink "https://www.inbucket.org" "Inbucket" - , text " is an open source projected hosted at " + , text " is an open source project hosted on " , externalLink "https://github.com/jhillyerd/inbucket" "GitHub" , text "." ] @@ -98,8 +166,8 @@ frameModal maybeModal = text "" -errorFlash : FrameControls msg -> Maybe Session.Flash -> Html msg -errorFlash controls maybeFlash = +errorFlash : Model msg -> Maybe Session.Flash -> Html msg +errorFlash model maybeFlash = let row ( heading, message ) = tr [] @@ -115,7 +183,7 @@ errorFlash controls maybeFlash = div [ class "well well-error" ] [ div [ class "flash-header" ] [ h2 [] [ text flash.title ] - , a [ href "#", Events.onClick controls.clearFlash ] [ text "Close" ] + , a [ href "#", Events.onClick (ClearFlash |> model.mapMsg) ] [ text "Close" ] ] , div [ class "flash-table" ] (List.map row flash.table) ] @@ -129,21 +197,22 @@ externalLink url title = navbarLink : Page -> Route -> List (Html a) -> Page -> Html a navbarLink page route linkContent activePage = li [ classList [ ( "navbar-active", page == activePage ) ] ] - [ a [ class "navbar-active-bg", Route.href route ] linkContent ] + [ a [ Route.href route ] linkContent ] {-| Renders list of recent mailboxes, selecting the currently active mailbox. -} -navbarRecent : Page -> FrameControls msg -> Html msg -navbarRecent page controls = +navbarRecent : Page -> String -> Model msg -> Session -> Html msg +navbarRecent page activeMailbox model session = let + -- Active means we are viewing a specific mailbox. active = page == Mailbox -- Recent tab title is the name of the current mailbox when active. title = if active then - controls.recentActive + activeMailbox else "Recent Mailboxes" @@ -151,18 +220,46 @@ navbarRecent page controls = -- Mailboxes to show in recent list, doesn't include active mailbox. recentMailboxes = if active then - List.tail controls.recentOptions |> Maybe.withDefault [] + List.tail session.persistent.recentMailboxes |> Maybe.withDefault [] else - controls.recentOptions + session.persistent.recentMailboxes + + dropdownExpanded = + if model.recentVisible then + "true" + + else + "false" recentLink mailbox = a [ Route.href (Route.Mailbox mailbox) ] [ text mailbox ] in li - [ class "navbar-recent" - , classList [ ( "navbar-dropdown", True ), ( "navbar-active", active ) ] + [ class "navbar-dropdown-container" + , classList [ ( "navbar-active", active ) ] + , attribute "aria-haspopup" "true" + , ariaExpanded model.recentVisible + , Events.onMouseOver (ShowRecent True |> model.mapMsg) + , Events.onMouseOut (ShowRecent False |> model.mapMsg) ] - [ span [ class "navbar-active-bg" ] [ text title ] + [ span [ class "navbar-dropdown" ] + [ text title + , button + [ class "navbar-dropdown-button" + , Events.onClick (ShowRecent (not model.recentVisible) |> model.mapMsg) + ] + [ i [ class "fas fa-chevron-down" ] [] ] + ] , div [ class "navbar-dropdown-content" ] (List.map recentLink recentMailboxes) ] + + +ariaExpanded : Bool -> Attribute msg +ariaExpanded value = + attribute "aria-expanded" <| + if value then + "true" + + else + "false" diff --git a/ui/src/Main.elm b/ui/src/Main.elm index 10b50d9..1bd0833 100644 --- a/ui/src/Main.elm +++ b/ui/src/Main.elm @@ -23,9 +23,8 @@ import Url exposing (Url) type alias Model = - { page : PageModel - , mailboxName : String - , showMenu : Bool + { layout : Layout.Model Msg + , page : PageModel } @@ -62,9 +61,8 @@ init configValue location key = Home.init session initModel = - { page = Home subModel - , mailboxName = "" - , showMenu = False + { layout = Layout.init LayoutMsg + , page = Home subModel } route = @@ -81,10 +79,7 @@ type Msg | LinkClicked UrlRequest | SessionUpdated (Result D.Error Session.Persistent) | TimeZoneLoaded Time.Zone - | ClearFlash - | OnMailboxNameInput String - | ViewMailbox String - | ToggleMenu + | LayoutMsg Layout.Msg | HomeMsg Home.Msg | MailboxMsg Mailbox.Msg | MonitorMsg Monitor.Msg @@ -182,11 +177,6 @@ updateMain msg model session = , Cmd.none ) - ClearFlash -> - ( applyToModelSession Session.clearFlash model - , Cmd.none - ) - SessionUpdated (Ok persistent) -> ( updateSession model { session | persistent = persistent } , Cmd.none @@ -208,17 +198,15 @@ updateMain msg model session = , Cmd.none ) - OnMailboxNameInput name -> - ( { model | mailboxName = name }, Cmd.none ) - - ViewMailbox name -> - ( applyToModelSession Session.clearFlash { model | mailboxName = "" } - , Route.pushUrl session.key (Route.Mailbox name) + LayoutMsg subMsg -> + let + ( layout, newSession, cmd ) = + Layout.update subMsg model.layout session + in + ( updateSession { model | layout = layout } newSession + , cmd ) - ToggleMenu -> - ( { model | showMenu = not model.showMenu }, Cmd.none ) - _ -> updatePage msg model @@ -256,7 +244,7 @@ changeRouteTo route model = getSession model newModel = - { model | showMenu = False } + { model | layout = Layout.reset model.layout } in case route of Route.Unknown path -> @@ -372,17 +360,6 @@ view model = _ -> "" - controls = - { viewMailbox = ViewMailbox - , mailboxOnInput = OnMailboxNameInput - , mailboxValue = model.mailboxName - , recentOptions = session.persistent.recentMailboxes - , recentActive = mailbox - , clearFlash = ClearFlash - , showMenu = model.showMenu - , toggleMenu = ToggleMenu - } - framePage : Layout.Page -> (msg -> Msg) @@ -391,11 +368,13 @@ view model = framePage page toMsg { title, modal, content } = Document title [ Layout.frame - controls - session - page - (Maybe.map (Html.map toMsg) modal) - (List.map (Html.map toMsg) content) + { model = model.layout + , session = session + , activePage = page + , activeMailbox = mailbox + , modal = Maybe.map (Html.map toMsg) modal + , content = List.map (Html.map toMsg) content + } ] in case model.page of diff --git a/ui/src/Page/Mailbox.elm b/ui/src/Page/Mailbox.elm index be43ec0..73155a1 100644 --- a/ui/src/Page/Mailbox.elm +++ b/ui/src/Page/Mailbox.elm @@ -17,15 +17,16 @@ import Html.Attributes , id , placeholder , property + , tabindex , target , type_ , value ) -import Html.Events exposing (..) +import Html.Events as Events import Http exposing (Error) import HttpUtil -import Json.Decode as Decode exposing (Decoder) -import Json.Encode as Encode +import Json.Decode as D +import Json.Encode as E import Ports import Route import Task @@ -135,6 +136,7 @@ subscriptions model = type Msg = ListLoaded (Result HttpUtil.Error (List MessageHeader)) | ClickMessage MessageID + | ListKeyPress String Int | OpenMessage MessageID | CloseMessage | MessageLoaded (Result HttpUtil.Error Message) @@ -165,7 +167,7 @@ update msg model = ) OpenMessage id -> - updateOpenMessage model.session model id + updateOpenMessage model id CloseMessage -> case model.state of @@ -186,6 +188,14 @@ update msg model = , Cmd.none ) + ListKeyPress id keyCode -> + case keyCode of + 13 -> + updateOpenMessage model id + + _ -> + ( model, Cmd.none ) + ListLoaded (Ok headers) -> case model.state of LoadingList selection -> @@ -197,7 +207,7 @@ update msg model = in case selection of Just id -> - updateOpenMessage model.session newModel id + updateOpenMessage newModel id Nothing -> ( { newModel @@ -458,8 +468,8 @@ updateMarkMessageSeen model message = ( model, Cmd.none ) -updateOpenMessage : Session -> Model -> String -> ( Model, Cmd Msg ) -updateOpenMessage session model id = +updateOpenMessage : Model -> String -> ( Model, Cmd Msg ) +updateOpenMessage model id = let newModel = { model | session = Session.addRecent model.mailboxName model.session } @@ -492,12 +502,12 @@ view model = [ input [ type_ "search" , placeholder "search" - , onInput OnSearchInput + , Events.onInput OnSearchInput , value model.searchInput ] [] , button - [ onClick PurgeMailboxPrompt + [ Events.onClick PurgeMailboxPrompt , alt "Purge Mailbox" ] [ i [ class "fas fa-trash" ] [] ] @@ -533,8 +543,8 @@ viewModal promptPurge = div [] [ p [] [ text "Are you sure you want to delete all messages in this mailbox?" ] , div [ class "button-bar" ] - [ button [ onClick PurgeMailboxConfirmed, class "danger" ] [ text "Yes" ] - , button [ onClick PurgeMailboxCanceled ] [ text "Cancel" ] + [ button [ Events.onClick PurgeMailboxConfirmed, class "danger" ] [ text "Yes" ] + , button [ Events.onClick PurgeMailboxCanceled ] [ text "Cancel" ] ] ] @@ -559,12 +569,14 @@ viewMessageList session model = messageChip : Model -> Maybe MessageID -> MessageHeader -> Html Msg messageChip model selected message = div - [ classList - [ ( "message-list-entry", True ) - , ( "selected", selected == Just message.id ) + [ class "message-list-entry" + , classList + [ ( "selected", selected == Just message.id ) , ( "unseen", not message.seen ) ] - , onClick (ClickMessage message.id) + , Events.onClick (ClickMessage message.id) + , onKeyUp (ListKeyPress message.id) + , tabindex 0 ] [ div [ class "subject" ] [ text message.subject ] , div [ class "from" ] [ text message.from ] @@ -586,18 +598,16 @@ viewMessage zone message bodyMode = text "" else - a - [ href htmlUrl, target "_blank" ] - [ button [] [ text "Raw HTML" ] ] + a [ href htmlUrl, target "_blank" ] + [ button [ tabindex -1 ] [ text "Raw HTML" ] ] in div [] [ div [ class "button-bar" ] - [ button [ class "message-close light", onClick CloseMessage ] + [ button [ class "message-close light", Events.onClick CloseMessage ] [ i [ class "fas fa-arrow-left" ] [] ] - , button [ class "danger", onClick (DeleteMessage message) ] [ text "Delete" ] - , a - [ href sourceUrl, target "_blank" ] - [ button [] [ text "Source" ] ] + , button [ class "danger", Events.onClick (DeleteMessage message) ] [ text "Delete" ] + , a [ href sourceUrl, target "_blank" ] + [ button [ tabindex -1 ] [ text "Source" ] ] , htmlButton ] , dl [ class "message-header" ] @@ -644,7 +654,7 @@ messageBody message bodyMode = bodyModeTab mode label = a [ classList [ ( "active", bodyMode == mode ) ] - , onClick (MessageBody mode) + , Events.onClick (MessageBody mode) , href "#" ] [ text label ] @@ -667,10 +677,10 @@ messageBody message bodyMode = , article [ class "message-body" ] [ case bodyMode of SafeHtmlBody -> - Html.node "rendered-html" [ property "content" (Encode.string message.html) ] [] + Html.node "rendered-html" [ property "content" (E.string message.html) ] [] TextBody -> - Html.node "rendered-html" [ property "content" (Encode.string message.text) ] [] + Html.node "rendered-html" [ property "content" (E.string message.text) ] [] ] ] @@ -750,3 +760,8 @@ filterMessageList list = || String.contains list.searchFilter (String.toLower header.from) in List.filter matches list.headers + + +onKeyUp : (Int -> msg) -> Attribute msg +onKeyUp tagger = + Events.on "keyup" (D.map tagger Events.keyCode) diff --git a/ui/src/Page/Monitor.elm b/ui/src/Page/Monitor.elm index 2fdcf60..855e831 100644 --- a/ui/src/Page/Monitor.elm +++ b/ui/src/Page/Monitor.elm @@ -36,6 +36,7 @@ type Msg | MessageReceived D.Value | Clear | OpenMessage MessageHeader + | MessageKeyPress MessageHeader Int update : Msg -> Model -> ( Model, Cmd Msg ) @@ -69,9 +70,22 @@ update msg model = ( { model | messages = [] }, Cmd.none ) OpenMessage header -> - ( model - , Route.pushUrl model.session.key (Route.Message header.mailbox header.id) - ) + openMessage header model + + MessageKeyPress header keyCode -> + case keyCode of + 13 -> + openMessage header model + + _ -> + ( model, Cmd.none ) + + +openMessage : MessageHeader -> Model -> ( Model, Cmd Msg ) +openMessage header model = + ( model + , Route.pushUrl model.session.key (Route.Message header.mailbox header.id) + ) @@ -121,7 +135,11 @@ view model = viewMessage : Time.Zone -> MessageHeader -> Html Msg viewMessage zone message = - tr [ Events.onClick (OpenMessage message) ] + tr + [ tabindex 0 + , Events.onClick (OpenMessage message) + , onKeyUp (MessageKeyPress message) + ] [ td [] [ shortDate zone message.date ] , td [ class "desktop" ] [ text message.from ] , td [] [ text message.mailbox ] @@ -147,3 +165,8 @@ shortDate zone date = zone date |> text + + +onKeyUp : (Int -> msg) -> Attribute msg +onKeyUp tagger = + Events.on "keyup" (D.map tagger Events.keyCode) diff --git a/ui/src/mailbox.css b/ui/src/mailbox.css index e61bf13..353b81e 100644 --- a/ui/src/mailbox.css +++ b/ui/src/mailbox.css @@ -18,6 +18,18 @@ display: flex; } +.message-list-controls button, +.message-list-controls input[type="search"] { + border: 1px solid var(--border-color); + border-radius: 3px; +} + +.message-list-controls button { + color: var(--low-color); + margin-left: 1px; + padding: 0 6px; +} + .message-list-controls input[type="search"] { flex: 1 1 auto; padding: 2px 4px; diff --git a/ui/src/main.css b/ui/src/main.css index e4809f3..9039b20 100644 --- a/ui/src/main.css +++ b/ui/src/main.css @@ -15,7 +15,7 @@ h1, h2, h3, h4, h5, h6, p, blockquote, pre, a, abbr, acronym, address, big, cite, code, del, dfn, em, img, ins, kbd, q, s, samp, small, strike, strong, sub, sup, tt, var, -b, u, i, center, +b, u, i, center, button, dl, dt, dd, ol, ul, li, fieldset, form, label, legend, table, caption, tbody, tfoot, thead, tr, th, td, @@ -44,13 +44,17 @@ body { background-color: var(--bg-color); } -body, input, table { +body, button, input, table { font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 14px; line-height: 1.43; color: var(--primary-color); } +button { + background: none; +} + h1, h2, h3, h4, h5, h6, p { margin-bottom: 10px; } diff --git a/ui/src/navbar.css b/ui/src/navbar.css index 3f1e09c..9bed562 100644 --- a/ui/src/navbar.css +++ b/ui/src/navbar.css @@ -30,29 +30,35 @@ .navbar-toggle { color: var(--navbar-color); font-size: 24px; + padding: 0 6px; position: absolute; top: 10px; - right: 20px; + right: 14px; } .navbar a, -.navbar-dropdown span { +.navbar-dropdown { color: var(--navbar-color); display: block; padding: 15px; text-decoration: none; } +/* This takes precendence over .navbar a above */ .navbar-brand a { display: inline-block; padding: 12px 15px; } +.navbar-dropdown-button { + display: none; +} + .navbar li { color: var(--navbar-color); } -li.navbar-active .navbar-active-bg { +li.navbar-active > *:first-child { background-color: #080808; } @@ -83,8 +89,7 @@ li.navbar-active span, } @media screen and (min-width: 1000px) { - .main-nav, - .navbar-bg { + .main-nav { height: var(--navbar-height); } @@ -110,16 +115,17 @@ li.navbar-active span, background-image: var(--navbar-image); grid-column: 1 / 4; grid-row: 1; + height: var(--navbar-height); width: 100%; z-index: -1; } - .navbar-brand { - margin-left: -15px; + .navbar-toggle { + display: none; } - .navbar-recent { - margin: 0 auto; + .navbar-brand { + margin-left: -15px; } .navbar-mailbox { @@ -131,6 +137,22 @@ li.navbar-active span, margin-top: 1px; } + .navbar-dropdown-container { + margin: 0 auto; + } + + .navbar-dropdown { + padding: 15px 19px 15px 25px; + } + + .navbar-dropdown-button { + background: none; + border: none; + color: var(--navbar-color); + display: inline; + margin-left: 3px; + } + .navbar-dropdown-content { background-color: var(--bg-color); border: 1px solid var(--border-color); @@ -143,7 +165,7 @@ li.navbar-active span, z-index: 1; } - .navbar-dropdown:hover .navbar-dropdown-content { + .navbar-dropdown-container[aria-expanded="true"] .navbar-dropdown-content { display: block; } @@ -156,8 +178,4 @@ li.navbar-active span, color: var(--primary-color) !important; background-color: var(--selected-color); } - - .navbar-toggle { - display: none; - } }