From 24c3ce2289699e548f7174fce993d8825f0a7856 Mon Sep 17 00:00:00 2001 From: cinereal Date: Mon, 27 Apr 2026 23:17:38 +0200 Subject: [PATCH] feat(frontend): add debounced typeahead suggestions to search input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a per-keystroke dropdown that fires a small ES query (`size: 8`) against the existing `*.edge` ngram subfields with a 150 ms debounce and stale-token drop. Suggestions are anchor links; clicking navigates to the package or option detail. The Flakes page leaves the dropdown disabled (its index is not channel-shaped). Honors `navigator.connection.saveData` and `effectiveType in {slow-2g, 2g}` via a new flag — when set, the typeahead is disabled and behavior matches the pre-change submit-on-enter UX. Closes the dropdown on submit, Escape, and blur (with a 200 ms grace period so a click on a suggestion still navigates). Re-focusing the input re-shows cached suggestions. HTML tags in description fields are stripped before display. --- frontend/src/Main.elm | 19 +- frontend/src/Page/Flakes.elm | 17 +- frontend/src/Page/Options.elm | 13 +- frontend/src/Page/Packages.elm | 10 +- frontend/src/Search.elm | 90 ++++++- frontend/src/Search/Typeahead.elm | 428 ++++++++++++++++++++++++++++++ frontend/src/index.js | 11 + frontend/src/index.scss | 47 ++++ 8 files changed, 606 insertions(+), 29 deletions(-) create mode 100644 frontend/src/Search/Typeahead.elm diff --git a/frontend/src/Main.elm b/frontend/src/Main.elm index 9c648797..dfbb1fe6 100644 --- a/frontend/src/Main.elm +++ b/frontend/src/Main.elm @@ -55,6 +55,7 @@ type alias Flags = , elasticsearchUsername : String , elasticsearchPassword : String , nixosChannels : Json.Decode.Value + , saveData : Bool } @@ -65,6 +66,7 @@ type alias Model = , defaultNixOSChannel : String , nixosChannels : List NixOSChannel , page : Page + , typeaheadEnabled : Bool } @@ -107,6 +109,7 @@ init flags url navKey = , nixosChannels = nixosChannels.channels , page = NotFound , route = Route.Home + , typeaheadEnabled = not flags.saveData } in changeRouteTo model url @@ -225,17 +228,17 @@ pageMatch m1 m2 = True ( Packages model_a, Packages model_b ) -> - { model_a | show = Nothing, showInstallDetails = Search.Unset, result = NotAsked } - == { model_b | show = Nothing, showInstallDetails = Search.Unset, result = NotAsked } + { model_a | show = Nothing, showInstallDetails = Search.Unset, result = NotAsked, typeahead = model_b.typeahead } + == { model_b | show = Nothing, showInstallDetails = Search.Unset, result = NotAsked, typeahead = model_b.typeahead } ( Options model_a, Options model_b ) -> - { model_a | show = Nothing, result = NotAsked } == { model_b | show = Nothing, result = NotAsked } + { model_a | show = Nothing, result = NotAsked, typeahead = model_b.typeahead } == { model_b | show = Nothing, result = NotAsked, typeahead = model_b.typeahead } ( Flakes (OptionModel model_a), Flakes (OptionModel model_b) ) -> - { model_a | show = Nothing, result = NotAsked } == { model_b | show = Nothing, result = NotAsked } + { model_a | show = Nothing, result = NotAsked, typeahead = model_b.typeahead } == { model_b | show = Nothing, result = NotAsked, typeahead = model_b.typeahead } ( Flakes (PackagesModel model_a), Flakes (PackagesModel model_b) ) -> - { model_a | show = Nothing, result = NotAsked } == { model_b | show = Nothing, result = NotAsked } + { model_a | show = Nothing, result = NotAsked, typeahead = model_b.typeahead } == { model_b | show = Nothing, result = NotAsked, typeahead = model_b.typeahead } _ -> False @@ -280,7 +283,7 @@ changeRouteTo currentModel url = _ -> Nothing in - Page.Packages.init searchArgs currentModel.defaultNixOSChannel currentModel.nixosChannels modelPage + Page.Packages.init currentModel.elasticsearch currentModel.typeaheadEnabled searchArgs currentModel.defaultNixOSChannel currentModel.nixosChannels modelPage |> updateWith Packages PackagesMsg model |> avoidReinit |> attemptQuery @@ -295,7 +298,7 @@ changeRouteTo currentModel url = _ -> Nothing in - Page.Options.init searchArgs currentModel.defaultNixOSChannel currentModel.nixosChannels modelPage + Page.Options.init currentModel.elasticsearch currentModel.typeaheadEnabled searchArgs currentModel.defaultNixOSChannel currentModel.nixosChannels modelPage |> updateWith Options OptionsMsg model |> avoidReinit |> attemptQuery @@ -310,7 +313,7 @@ changeRouteTo currentModel url = _ -> Nothing in - Page.Flakes.init searchArgs currentModel.defaultNixOSChannel currentModel.nixosChannels modelPage + Page.Flakes.init currentModel.elasticsearch currentModel.typeaheadEnabled searchArgs currentModel.defaultNixOSChannel currentModel.nixosChannels modelPage |> updateWith Flakes FlakesMsg model |> avoidReinit |> attemptQuery diff --git a/frontend/src/Page/Flakes.elm b/frontend/src/Page/Flakes.elm index d0b23511..cd42be9b 100644 --- a/frontend/src/Page/Flakes.elm +++ b/frontend/src/Page/Flakes.elm @@ -51,22 +51,29 @@ type Model init : - Route.SearchArgs + Search.Options + -> Bool + -> Route.SearchArgs -> String -> List NixOSChannel -> Maybe Model -> ( Model, Cmd Msg ) -init searchArgs defaultNixOSChannel nixosChannels model = +init options typeaheadEnabled searchArgs defaultNixOSChannel nixosChannels model = let -- init with respective module or with packages by default searchType : SearchType searchType = Maybe.withDefault PackageSearch searchArgs.type_ + + -- The Flakes page uses an aggregate flake index that the typeahead + -- module does not currently support; keep it disabled here. + flakesTypeaheadEnabled = + False in case searchType of OptionSearch -> Tuple.mapBoth OptionModel (Cmd.map OptionsMsg) <| - Page.Options.init searchArgs defaultNixOSChannel nixosChannels <| + Page.Options.init options flakesTypeaheadEnabled searchArgs defaultNixOSChannel nixosChannels <| case model of Just (OptionModel model_) -> Just model_ @@ -76,7 +83,7 @@ init searchArgs defaultNixOSChannel nixosChannels model = PackageSearch -> Tuple.mapBoth PackagesModel (Cmd.map PackagesMsg) <| - Page.Packages.init searchArgs defaultNixOSChannel nixosChannels <| + Page.Packages.init options flakesTypeaheadEnabled searchArgs defaultNixOSChannel nixosChannels <| case model of Just (PackagesModel model_) -> Just model_ @@ -179,7 +186,7 @@ view nixosChannels model = ) ) [ h1 [] bodyTitle - , viewSearchInput nixosChannels outMsg categoryName Nothing model_.query + , viewSearchInput nixosChannels outMsg categoryName Nothing model_.query Nothing , viewResult nixosChannels outMsg categoryName model_ viewSuccess viewBuckets <| viewFlakes outMsg model_.searchType ] diff --git a/frontend/src/Page/Options.elm b/frontend/src/Page/Options.elm index bab2a8af..757b1424 100644 --- a/frontend/src/Page/Options.elm +++ b/frontend/src/Page/Options.elm @@ -105,17 +105,22 @@ type alias AggregationsAll = init : - Route.SearchArgs + Search.Options + -> Bool + -> Route.SearchArgs -> String -> List NixOSChannel -> Maybe Model -> ( Model, Cmd Msg ) -init searchArgs defaultNixOSChannel nixosChannels model = +init options typeaheadEnabled searchArgs defaultNixOSChannel nixosChannels model = let ( newModel, newCmd ) = - Search.init searchArgs defaultNixOSChannel nixosChannels model + Search.init options typeaheadEnabled searchArgs defaultNixOSChannel nixosChannels model in - ( newModel + -- The Options page always queries options, regardless of `args.type_` + -- (which defaults to `PackageSearch`). Pin `searchType` so the typeahead + -- targets option fields. + ( { newModel | searchType = Route.OptionSearch } , Cmd.map SearchMsg newCmd ) diff --git a/frontend/src/Page/Packages.elm b/frontend/src/Page/Packages.elm index 44fb8164..0756e1ce 100644 --- a/frontend/src/Page/Packages.elm +++ b/frontend/src/Page/Packages.elm @@ -184,17 +184,19 @@ initBuckets bucketsAsString = init : - Route.SearchArgs + Search.Options + -> Bool + -> Route.SearchArgs -> String -> List NixOSChannel -> Maybe Model -> ( Model, Cmd Msg ) -init searchArgs defaultNixOSChannel nixosChannels model = +init options typeaheadEnabled searchArgs defaultNixOSChannel nixosChannels model = let ( newModel, newCmd ) = - Search.init searchArgs defaultNixOSChannel nixosChannels model + Search.init options typeaheadEnabled searchArgs defaultNixOSChannel nixosChannels model in - ( newModel + ( { newModel | searchType = Route.PackageSearch } , Cmd.map SearchMsg newCmd ) diff --git a/frontend/src/Search.elm b/frontend/src/Search.elm index d1c96139..961cf90c 100644 --- a/frontend/src/Search.elm +++ b/frontend/src/Search.elm @@ -72,7 +72,10 @@ import Html.Attributes ) import Html.Events exposing - ( onClick + ( on + , onBlur + , onClick + , onFocus , onInput , onSubmit ) @@ -89,6 +92,7 @@ import Route , allTypes , searchTypeToTitle ) +import Search.Typeahead as Typeahead import Set exposing (Set) import Task @@ -108,6 +112,8 @@ type alias Model a b = , searchType : Route.SearchType , redirectedChannel : Maybe String , excludedOptionSources : Set String + , options : Options + , typeahead : Typeahead.Model } @@ -287,12 +293,14 @@ decodeResolvedFlake = init : - Route.SearchArgs + Options + -> Bool + -> Route.SearchArgs -> String -> List NixOSChannel -> Maybe (Model a b) -> ( Model a b, Cmd (Msg a b) ) -init args defaultNixOSChannel nixosChannels maybeModel = +init options typeaheadEnabled args defaultNixOSChannel nixosChannels maybeModel = let getField getFn default = maybeModel @@ -346,6 +354,8 @@ init args defaultNixOSChannel nixosChannels maybeModel = args.type_ |> Maybe.withDefault defaultSearchArgs.searchType , excludedOptionSources = args.excludedOptionSources + , options = options + , typeahead = Typeahead.init typeaheadEnabled } |> ensureLoading nixosChannels , Browser.Dom.focus "search-query-input" |> Task.attempt (\_ -> NoOp) @@ -415,6 +425,9 @@ type Msg a b | ChangePage Int | ShowInstallDetails Details | SetOptionSourceIncluded OptionSource Bool + | TypeaheadMsg Typeahead.Msg + | TypeaheadBlur + | TypeaheadFocus type Details @@ -505,8 +518,18 @@ update toRoute navKey msg model nixosChannels = |> pushUrl toRoute navKey QueryInput query -> - ( { model | query = query } - , Cmd.none + let + ( typeaheadModel, typeaheadCmd ) = + Typeahead.queryChanged + model.options + nixosChannels + model.searchType + model.channel + query + model.typeahead + in + ( { model | query = query, typeahead = typeaheadModel } + , Cmd.map TypeaheadMsg typeaheadCmd ) QueryInputSubmit -> @@ -514,6 +537,7 @@ update toRoute navKey msg model nixosChannels = | from = 0 , show = Nothing , buckets = Nothing + , typeahead = Typeahead.hideModel model.typeahead } |> ensureLoading nixosChannels |> pushUrl toRoute navKey @@ -545,6 +569,30 @@ update toRoute navKey msg model nixosChannels = { model | showInstallDetails = details } |> pushUrl toRoute navKey + TypeaheadBlur -> + ( model, Cmd.map TypeaheadMsg Typeahead.hideAfterBlur ) + + TypeaheadFocus -> + ( { model | typeahead = Typeahead.focusModel model.typeahead } + , Cmd.none + ) + + TypeaheadMsg subMsg -> + let + ( typeaheadModel, typeaheadCmd ) = + Typeahead.update + model.options + nixosChannels + model.searchType + model.channel + model.query + subMsg + model.typeahead + in + ( { model | typeahead = typeaheadModel } + , Cmd.map TypeaheadMsg typeaheadCmd + ) + SetOptionSourceIncluded source included -> let id = @@ -815,7 +863,7 @@ view { categoryName } title nixosChannels model viewSuccess viewBuckets outMsg s ) ) ([ h1 [] title - , viewSearchInput nixosChannels outMsg categoryName (Just model.channel) model.query + , viewSearchInput nixosChannels outMsg categoryName (Just model.channel) model.query (Just model.typeahead) ] ++ (case model.redirectedChannel of Just oldChannel -> @@ -1028,23 +1076,33 @@ viewSearchInput : -> String -> Maybe String -> String + -> Maybe Typeahead.Model -> Html c -viewSearchInput nixosChannels outMsg categoryName selectedChannel searchQuery = +viewSearchInput nixosChannels outMsg categoryName selectedChannel searchQuery typeahead = form [ onSubmit (outMsg QueryInputSubmit) , class "search-input" ] (div [] - [ div [] + [ div [ class "search-input-with-typeahead" ] [ input [ type_ "text" , id "search-query-input" , autofocus True , placeholder <| "Search for " ++ categoryName , onInput (outMsg << QueryInput) + , onFocus (outMsg TypeaheadFocus) + , onBlur (outMsg TypeaheadBlur) + , on "keydown" (escapeKeyDecoder (outMsg (TypeaheadMsg Typeahead.hide))) , value searchQuery ] [] + , case typeahead of + Just t -> + Typeahead.viewDropdown t + + Nothing -> + text "" ] , button [ class "btn", type_ "submit" ] [ text "Search" ] @@ -1621,3 +1679,19 @@ trapClick : Html.Attribute (Msg a b) trapClick = Html.Events.stopPropagationOn "click" <| Json.Decode.succeed ( NoOp, True ) + + +{-| Resolves to the supplied message when the Escape key is pressed; fails +the decoder otherwise so other keystrokes pass through unchanged. +-} +escapeKeyDecoder : c -> Json.Decode.Decoder c +escapeKeyDecoder msg = + Json.Decode.field "key" Json.Decode.string + |> Json.Decode.andThen + (\k -> + if k == "Escape" then + Json.Decode.succeed msg + + else + Json.Decode.fail "not Escape" + ) diff --git a/frontend/src/Search/Typeahead.elm b/frontend/src/Search/Typeahead.elm new file mode 100644 index 00000000..3fd06af8 --- /dev/null +++ b/frontend/src/Search/Typeahead.elm @@ -0,0 +1,428 @@ +module Search.Typeahead exposing + ( Model + , Msg + , disabled + , focusModel + , hide + , hideAfterBlur + , hideModel + , init + , queryChanged + , update + , viewDropdown + ) + +{-| Lightweight per-keystroke suggestions UI. + +Sits alongside the existing search form. The parent (`Search`) calls +`queryChanged` whenever the input changes; we debounce, fire a small +ES query against the existing `*.edge` subfields, and render a +dropdown of links the user can click to jump straight to a result. + +When `disabled` is true (data-saver mode), `queryChanged` returns no +Cmd and `viewDropdown` returns an empty node. + +-} + +import Base64 +import Html exposing (Html, a, li, span, text, ul) +import Html.Attributes exposing (class, href) +import Http +import Json.Decode +import Json.Decode.Pipeline +import Json.Encode +import Process +import Regex +import Route exposing (SearchType(..)) +import Task + + + +-- TYPES +-- +-- Function signatures use record-extension types instead of importing the +-- concrete `Search.Options` / `Search.NixOSChannel`, so this module does +-- not need to depend on `Search` (which would form an import cycle). + + +type alias Options r = + { r + | mappingSchemaVersion : Int + , url : String + , username : String + , password : String + } + + +type alias Channel r = + { r | id : String, branch : String } + + + +-- MODEL + + +type alias Model = + { token : Int + , suggestions : List Suggestion + , enabled : Bool + , visible : Bool + } + + +type alias Suggestion = + { primary : String + , secondary : Maybe String + , navigateTo : String + } + + +init : Bool -> Model +init enabled = + { token = 0 + , suggestions = [] + , enabled = enabled + , visible = False + } + + +disabled : Model -> Bool +disabled m = + not m.enabled + + + +-- DEBOUNCE + + +debounceMs : Float +debounceMs = + 150 + + +maxResults : Int +maxResults = + 8 + + + +-- UPDATE + + +type Msg + = Fire Int + | Response Int (Result Http.Error (List Suggestion)) + | Hide + + +{-| Synchronous "close the dropdown now" message — for submit and Escape. +-} +hide : Msg +hide = + Hide + + +{-| Direct model update equivalent to dispatching `hide`. Useful when the +parent already has the model in hand and would rather not round-trip +through `update` for context arguments it doesn't have. +-} +hideModel : Model -> Model +hideModel m = + { m | visible = False } + + +{-| Re-show the dropdown if we already have suggestions cached. Called on +input focus so the user can see prior matches without retyping. +-} +focusModel : Model -> Model +focusModel m = + if m.enabled && not (List.isEmpty m.suggestions) then + { m | visible = True } + + else + m + + +{-| Delayed "close the dropdown" message — for blur. The delay gives the +browser time to dispatch a click on a suggestion link before we tear it +down (otherwise the click target is gone before navigation triggers). +-} +hideAfterBlur : Cmd Msg +hideAfterBlur = + Process.sleep 200 |> Task.perform (\_ -> Hide) + + +queryChanged : + Options r + -> List (Channel c) + -> SearchType + -> String + -> String + -> Model + -> ( Model, Cmd Msg ) +queryChanged _ _ _ _ query model = + if not model.enabled then + ( model, Cmd.none ) + + else + let + trimmed = + String.trim query + + nextToken = + model.token + 1 + in + if String.length trimmed < 2 then + ( { model | token = nextToken, suggestions = [], visible = False } + , Cmd.none + ) + + else + ( { model | token = nextToken, visible = True } + , Process.sleep debounceMs |> Task.perform (\_ -> Fire nextToken) + ) + + +update : + Options r + -> List (Channel c) + -> SearchType + -> String + -> String + -> Msg + -> Model + -> ( Model, Cmd Msg ) +update options nixosChannels searchType channel query msg model = + case msg of + Fire token -> + if token /= model.token then + ( model, Cmd.none ) + + else + ( model, fetch options nixosChannels searchType channel query token ) + + Response token result -> + if token /= model.token then + ( model, Cmd.none ) + + else + case result of + Ok suggestions -> + ( { model | suggestions = suggestions, visible = True }, Cmd.none ) + + Err _ -> + ( { model | suggestions = [], visible = False }, Cmd.none ) + + Hide -> + ( { model | visible = False }, Cmd.none ) + + + +-- VIEW + + +viewDropdown : Model -> Html msg +viewDropdown model = + if not model.enabled || not model.visible || List.isEmpty model.suggestions then + text "" + + else + ul [ class "typeahead-suggestions" ] + (List.map viewSuggestion model.suggestions) + + +viewSuggestion : Suggestion -> Html msg +viewSuggestion s = + li [ class "typeahead-item" ] + [ a [ href s.navigateTo ] + [ span [ class "typeahead-primary" ] [ text s.primary ] + , case s.secondary of + Just sec -> + span [ class "typeahead-secondary" ] [ text sec ] + + Nothing -> + text "" + ] + ] + + + +-- HTTP + + +fetch : + Options r + -> List (Channel c) + -> SearchType + -> String + -> String + -> Int + -> Cmd Msg +fetch options nixosChannels searchType channel query token = + let + branch = + nixosChannels + |> List.filter (\c -> c.id == channel) + |> List.head + |> Maybe.map .branch + |> Maybe.withDefault channel + + index = + "latest-" ++ String.fromInt options.mappingSchemaVersion ++ "-" ++ branch + + body = + requestBody searchType (String.trim query) + in + Http.riskyRequest + { method = "POST" + , headers = + [ Http.header "Authorization" + ("Basic " ++ Base64.encode (options.username ++ ":" ++ options.password)) + ] + , url = options.url ++ "/" ++ index ++ "/_search" + , body = body + , expect = Http.expectJson (Response token) (decodeSuggestions searchType channel) + , timeout = Just 4000 + , tracker = Just "typeahead" + } + + +requestBody : SearchType -> String -> Http.Body +requestBody searchType query = + let + ( typeFilter, edgeFields ) = + case searchType of + PackageSearch -> + ( "package" + , [ ( "package_attr_name.edge", 4.0 ) + , ( "package_pname.edge", 3.0 ) + , ( "package_description.edge", 0.5 ) + ] + ) + + OptionSearch -> + ( "option" + , [ ( "option_name.edge", 4.0 ) + , ( "option_description.edge", 0.5 ) + ] + ) + + fieldsArray = + edgeFields + |> List.map (\( f, b ) -> f ++ "^" ++ String.fromFloat b) + in + Http.jsonBody + (Json.Encode.object + [ ( "from", Json.Encode.int 0 ) + , ( "size", Json.Encode.int maxResults ) + , ( "_source" + , Json.Encode.list Json.Encode.string + [ "type" + , "package_attr_name" + , "package_pname" + , "package_description" + , "option_name" + , "option_description" + ] + ) + , ( "query" + , Json.Encode.object + [ ( "bool" + , Json.Encode.object + [ ( "filter" + , Json.Encode.list Json.Encode.object + [ [ ( "term" + , Json.Encode.object + [ ( "type", Json.Encode.string typeFilter ) ] + ) + ] + ] + ) + , ( "must" + , Json.Encode.list Json.Encode.object + [ [ ( "multi_match" + , Json.Encode.object + [ ( "query", Json.Encode.string query ) + , ( "type", Json.Encode.string "best_fields" ) + , ( "operator", Json.Encode.string "and" ) + , ( "fields", Json.Encode.list Json.Encode.string fieldsArray ) + ] + ) + ] + ] + ) + ] + ) + ] + ) + ] + ) + + +decodeSuggestions : SearchType -> String -> Json.Decode.Decoder (List Suggestion) +decodeSuggestions searchType channel = + Json.Decode.at [ "hits", "hits" ] + (Json.Decode.list (decodeHit searchType channel)) + + +decodeHit : SearchType -> String -> Json.Decode.Decoder Suggestion +decodeHit searchType channel = + Json.Decode.field "_source" (decodeSource searchType channel) + + +decodeSource : SearchType -> String -> Json.Decode.Decoder Suggestion +decodeSource searchType channel = + case searchType of + PackageSearch -> + Json.Decode.succeed + (\attr pname description -> + { primary = attr + , secondary = + if attr == pname || pname == "" then + Maybe.map stripHtml description + + else + Just pname + , navigateTo = + "/packages?channel=" ++ channel ++ "&show=" ++ attr ++ "&query=" ++ attr + } + ) + |> Json.Decode.Pipeline.required "package_attr_name" Json.Decode.string + |> Json.Decode.Pipeline.optional "package_pname" Json.Decode.string "" + |> Json.Decode.Pipeline.optional "package_description" + (Json.Decode.map Just Json.Decode.string) + Nothing + + OptionSearch -> + Json.Decode.succeed + (\name description -> + { primary = name + , secondary = Maybe.map stripHtml description + , navigateTo = + "/options?channel=" ++ channel ++ "&show=" ++ name ++ "&query=" ++ name + } + ) + |> Json.Decode.Pipeline.required "option_name" Json.Decode.string + |> Json.Decode.Pipeline.optional "option_description" + (Json.Decode.map Just Json.Decode.string) + Nothing + + +{-| Description fields come pre-rendered as HTML (e.g. `

`). +The dropdown is plain text, so strip tags and collapse whitespace before display. +-} +stripHtml : String -> String +stripHtml s = + let + tagRegex = + Regex.fromString "<[^>]*>" + |> Maybe.withDefault Regex.never + + wsRegex = + Regex.fromString "\\s+" + |> Maybe.withDefault Regex.never + in + s + |> Regex.replace tagRegex (\_ -> " ") + |> Regex.replace wsRegex (\_ -> " ") + |> String.trim diff --git a/frontend/src/index.js b/frontend/src/index.js index 20d7a466..2c1a8e49 100644 --- a/frontend/src/index.js +++ b/frontend/src/index.js @@ -5,6 +5,16 @@ require("elm-keyboard-shortcut"); const { Elm } = require("./Main"); +// Honor the user's data-saver preference so the typeahead can disable +// itself on metered or slow connections. Falsy default for browsers +// without the Network Information API. +const saveData = Boolean( + typeof navigator !== "undefined" && + navigator.connection && + (navigator.connection.saveData || + ["slow-2g", "2g"].includes(navigator.connection.effectiveType)), +); + Elm.Main.init({ flags: { elasticsearchMappingSchemaVersion: parseInt( @@ -16,6 +26,7 @@ Elm.Main.init({ elasticsearchPassword: process.env.ELASTICSEARCH_PASSWORD || "X8gPHnzL52wFEekuxsfQ9cSh", nixosChannels: JSON.parse(process.env.NIXOS_CHANNELS), + saveData: saveData, }, }); diff --git a/frontend/src/index.scss b/frontend/src/index.scss index fe8d1398..355e9b5d 100644 --- a/frontend/src/index.scss +++ b/frontend/src/index.scss @@ -267,6 +267,53 @@ border-left: 1px solid var(--line-color); } } + + .search-input-with-typeahead { + position: relative; + } + + .typeahead-suggestions { + position: absolute; + top: 100%; + left: 0; + right: 0; + margin: 4px 0 0; + padding: 4px 0; + list-style: none; + background: var(--background-color); + border: 1px solid var(--line-color); + border-radius: 4px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.12); + z-index: 1000; + max-height: 50vh; + overflow-y: auto; + + .typeahead-item { + a { + display: flex; + flex-direction: column; + padding: 6px 12px; + color: var(--text-color); + text-decoration: none; + + &:hover, &:focus { + background: var(--button-active-background); + } + } + + .typeahead-primary { + font-weight: 600; + } + + .typeahead-secondary { + font-size: 0.85em; + color: var(--text-color-light); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + } + } } // Override bootstrap styles for the header bar