diff --git a/scaladoc-js/common/css/searchbar.css b/scaladoc-js/common/css/searchbar.css index 103b830e0ca4..03f5390e3077 100644 --- a/scaladoc-js/common/css/searchbar.css +++ b/scaladoc-js/common/css/searchbar.css @@ -156,6 +156,10 @@ div[selected] > .scaladoc-searchbar-inkuire-package { .search span:hover { fill: var(--link-hover-fg); } + + #scaladoc-searchbar span.pull-right { + display: none; + } } #scaladoc-search { @@ -177,12 +181,11 @@ div[selected] > .scaladoc-searchbar-inkuire-package { left: calc(5% + var(--side-width)); z-index: 5; width: calc(90% - var(--side-width)); - box-shadow: 0 2px 16px 0 rgba(0, 42, 76, 0.15); + box-shadow: 2px 2px 8px 0 var(--shadow); font-size: 13px; font-family: system-ui, -apple-system, Segoe UI, Roboto, Noto Sans, Ubuntu, Cantarell, Helvetica Neue, Arial, sans-serif; background-color: var(--leftbar-bg); color: var(--leftbar-fg); - box-shadow: 0 0 2px var(--shadow); } #scaladoc-searchbar-input { @@ -206,45 +209,74 @@ div[selected] > .scaladoc-searchbar-inkuire-package { overflow: auto; } -.scaladoc-searchbar-result { +.scaladoc-searchbar-row { + display: flex; background-color: var(--leftbar-bg); color: var(--leftbar-fg); line-height: 24px; padding: 4px 10px 4px 10px; } -.scaladoc-searchbar-result-row { - display: flex; +.scaladoc-searchbar-row.hidden { + display: none; +} + +.scaladoc-searchbar-row[divider] { + border-top: solid 1px var(--leftbar-border); } -.scaladoc-searchbar-result .micon { +.scaladoc-searchbar-row .micon { height: 16px; width: 16px; margin: 4px 8px 0px 0px; } -.scaladoc-searchbar-result:first-of-type { - margin-top: 10px; -} - -.scaladoc-searchbar-result[selected] { +.scaladoc-searchbar-row[selected] { background-color: var(--leftbar-hover-bg); color: var(--leftbar-hover-fg); } -.scaladoc-searchbar-result a { - /* for some reason, with display:block if there's a wrap between the - * search result text and the location span, the dead space to the - * left of the location span doesn't get treated as part of the block, - * which defeats the purpose of making the a block element. - * But inline-block with width:100% works as desired. - */ - display: inline-block; - width: 100%; +.scaladoc-searchbar-row[result] { + flex-direction: column; +} + +.scaladoc-searchbar-row[result] a { text-indent: -20px; padding-left: 20px; } +.scaladoc-searchbar-row[loadmore] { + align-items: center; + cursor: pointer; +} + +.scaladoc-searchbar-row[loadmore] > a { + display: flex; + align-items: center; + width: 100%; +} + +.scaladoc-searchbar-row[loadmore] .i { + margin-left: 4px; + margin-right: 4px; +} + +.searchbar-hints { + padding-top: 5vh; + padding-bottom: 5vh; + padding-left: 5vw; + padding-right: 5vw; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; +} + +.searchbar-hints-list { + font-size: medium; + line-height: 2em; +} + #searchBar { display: inline-flex; } diff --git a/scaladoc-js/main/src/searchbar/SearchbarComponent.scala b/scaladoc-js/main/src/searchbar/SearchbarComponent.scala index 9741c6681024..c795b4aa634a 100644 --- a/scaladoc-js/main/src/searchbar/SearchbarComponent.scala +++ b/scaladoc-js/main/src/searchbar/SearchbarComponent.scala @@ -9,18 +9,15 @@ import scala.concurrent.duration._ import java.net.URI class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearchEngine, parser: QueryParser): - val resultsChunkSize = 100 + val initialChunkSize = 5 + val resultsChunkSize = 20 extension (p: PageEntry) def toHTML = val wrapper = document.createElement("div").asInstanceOf[html.Div] - wrapper.classList.add("scaladoc-searchbar-result") - wrapper.classList.add("scaladoc-searchbar-result-row") + wrapper.classList.add("scaladoc-searchbar-row") + wrapper.setAttribute("result", "") wrapper.classList.add("monospace") - val icon = document.createElement("span").asInstanceOf[html.Span] - icon.classList.add("micon") - icon.classList.add(p.kind.take(2)) - val resultA = document.createElement("a").asInstanceOf[html.Anchor] resultA.href = if (p.isLocationExternal) { @@ -39,7 +36,6 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch location.classList.add("scaladoc-searchbar-location") location.textContent = p.description - wrapper.appendChild(icon) wrapper.appendChild(resultA) resultA.appendChild(location) wrapper.addEventListener("mouseover", { @@ -50,27 +46,22 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch extension (m: InkuireMatch) def toHTML = val wrapper = document.createElement("div").asInstanceOf[html.Div] - wrapper.classList.add("scaladoc-searchbar-result") + wrapper.classList.add("scaladoc-searchbar-row") + wrapper.setAttribute("result", "") + wrapper.setAttribute("inkuire-result", "") wrapper.classList.add("monospace") wrapper.setAttribute("mq", m.mq.toString) - val resultDiv = document.createElement("div").asInstanceOf[html.Div] - resultDiv.classList.add("scaladoc-searchbar-result-row") - - val icon = document.createElement("span").asInstanceOf[html.Span] - icon.classList.add("micon") - icon.classList.add(m.entryType.take(2)) - val resultA = document.createElement("a").asInstanceOf[html.Anchor] // Inkuire pageLocation should start with e (external) // or i (internal). The rest of the string is an absolute // or relative URL - resultA.href = + resultA.href = if (m.pageLocation(0) == 'e') { m.pageLocation.substring(1) } else { Globals.pathToRoot + m.pageLocation.substring(1) - } + } resultA.text = m.functionName resultA.onclick = (event: Event) => if (document.body.contains(rootDiv)) { @@ -92,9 +83,7 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch signature.classList.add("scaladoc-searchbar-inkuire-signature") signature.textContent = m.prettifiedSignature - wrapper.appendChild(resultDiv) - resultDiv.appendChild(icon) - resultDiv.appendChild(resultA) + wrapper.appendChild(resultA) resultA.appendChild(signature) wrapper.appendChild(packageDiv) packageDiv.appendChild(packageIcon) @@ -104,29 +93,85 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch }) wrapper + def createKindSeparator(kind: String) = + val kindSeparator = document.createElement("div").asInstanceOf[html.Div] + val icon = document.createElement("span").asInstanceOf[html.Span] + icon.classList.add("micon") + icon.classList.add(kind.take(2)) + val name = document.createElement("span").asInstanceOf[html.Span] + name.textContent = kind + kindSeparator.classList.add("scaladoc-searchbar-row") + kindSeparator.setAttribute("divider", "") + kindSeparator.classList.add("monospace") + kindSeparator.appendChild(icon) + kindSeparator.appendChild(name) + kindSeparator + def handleNewFluffQuery(matchers: List[Matchers]) = - val result = engine.query(matchers).map(_.toHTML) - resultsDiv.scrollTop = 0 - while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild) + val result = engine.query(matchers) val fragment = document.createDocumentFragment() - result.take(resultsChunkSize).foreach(fragment.appendChild) - resultsDiv.appendChild(fragment) - def loadMoreResults(result: List[raw.HTMLElement]): Unit = { - resultsDiv.onscroll = (event: Event) => { - if (resultsDiv.scrollHeight - resultsDiv.scrollTop == resultsDiv.clientHeight) { - val fragment = document.createDocumentFragment() - result.take(resultsChunkSize).foreach(fragment.appendChild) - resultsDiv.appendChild(fragment) - loadMoreResults(result.drop(resultsChunkSize)) + def createLoadMoreElement = + val loadMoreElement = document.createElement("div").asInstanceOf[html.Div] + loadMoreElement.classList.add("scaladoc-searchbar-row") + loadMoreElement.setAttribute("loadmore", "") + loadMoreElement.classList.add("monospace") + val icon = document.createElement("a").asInstanceOf[html.Anchor] + icon.classList.add("i") + icon.classList.add("fas") + icon.classList.add("fa-arrow-down") + val text = document.createElement("span").asInstanceOf[html.Span] + text.textContent = "Show more..." + val anchor = document.createElement("a").asInstanceOf[html.Anchor] + anchor.appendChild(icon) + anchor.appendChild(text) + loadMoreElement.appendChild(anchor) + loadMoreElement.addEventListener("mouseover", _ => handleHover(loadMoreElement)) + loadMoreElement + result.groupBy(_.kind).map { + case (kind, entries) => + val kindSeparator = createKindSeparator(kind) + val htmlEntries = entries.map(_.toHTML) + val loadMoreElement = createLoadMoreElement + def loadMoreResults(entries: List[raw.HTMLElement]): Unit = { + loadMoreElement.onclick = (event: Event) => { + entries.take(resultsChunkSize).foreach(_.classList.remove("hidden")) + val nextElems = entries.drop(resultsChunkSize) + if nextElems.nonEmpty then loadMoreResults(nextElems) else loadMoreElement.classList.add("hidden") + } } - } + + fragment.appendChild(kindSeparator) + htmlEntries.foreach(fragment.appendChild) + fragment.appendChild(loadMoreElement) + + val nextElems = htmlEntries.drop(initialChunkSize) + if nextElems.nonEmpty then { + nextElems.foreach(_.classList.add("hidden")) + loadMoreResults(nextElems) + } else { + loadMoreElement.classList.add("hidden") + } + } - loadMoreResults(result.drop(resultsChunkSize)) + + resultsDiv.scrollTop = 0 + while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild) + resultsDiv.appendChild(fragment) + + def createLoadingAnimation: raw.HTMLElement = { + val loading = document.createElement("div").asInstanceOf[html.Div] + loading.classList.add("loading-wrapper") + val animation = document.createElement("div").asInstanceOf[html.Div] + animation.classList.add("loading") + loading.appendChild(animation) + loading +} extension (s: String) def toHTMLError = val wrapper = document.createElement("div").asInstanceOf[html.Div] - wrapper.classList.add("scaladoc-searchbar-result") + wrapper.classList.add("scaladoc-searchbar-row") + wrapper.classList.add("scaladoc-searchbar-error") wrapper.classList.add("monospace") val errorSpan = document.createElement("span").asInstanceOf[html.Span] @@ -141,35 +186,30 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch clearTimeout(timeoutHandle) resultsDiv.scrollTop = 0 resultsDiv.onscroll = (event: Event) => { } - while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild) + def clearResults() = while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild) val fragment = document.createDocumentFragment() parser.parse(query) match { case EngineMatchersQuery(matchers) => + clearResults() handleNewFluffQuery(matchers) case BySignature(signature) => timeoutHandle = setTimeout(1.second) { - val properResultsDiv = document.createElement("div").asInstanceOf[html.Div] - resultsDiv.appendChild(properResultsDiv) - val loading = document.createElement("div").asInstanceOf[html.Div] - loading.classList.add("loading-wrapper") - val animation = document.createElement("div").asInstanceOf[html.Div] - animation.classList.add("loading") - loading.appendChild(animation) - properResultsDiv.appendChild(loading) + val loading = createLoadingAnimation + val kindSeparator = createKindSeparator("inkuire") + clearResults() + resultsDiv.appendChild(loading) + resultsDiv.appendChild(kindSeparator) inkuireEngine.query(query) { (m: InkuireMatch) => - val next = properResultsDiv.children.foldLeft[Option[Element]](None) { - case (acc, child) if !acc.isEmpty => acc - case (_, child) => - Option.when(child.hasAttribute("mq") && Integer.parseInt(child.getAttribute("mq")) > m.mq)(child) - } + val next = resultsDiv.children + .find(child => child.hasAttribute("mq") && Integer.parseInt(child.getAttribute("mq")) > m.mq) next.fold { - properResultsDiv.appendChild(m.toHTML) + resultsDiv.appendChild(m.toHTML) } { next => - properResultsDiv.insertBefore(m.toHTML, next) + resultsDiv.insertBefore(m.toHTML, next) } } { (s: String) => - animation.classList.remove("loading") - properResultsDiv.appendChild(s.toHTMLError) + resultsDiv.removeChild(loading) + resultsDiv.appendChild(s.toHTMLError) } } } @@ -202,7 +242,11 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch private val input: html.Input = val element = document.createElement("input").asInstanceOf[html.Input] element.id = "scaladoc-searchbar-input" - element.addEventListener("input", (e) => handleNewQuery(e.target.asInstanceOf[html.Input].value)) + element.addEventListener("input", { e => + val inputValue = e.target.asInstanceOf[html.Input].value + if inputValue.isEmpty then showHints() + else handleNewQuery(inputValue) + }) element.autocomplete = "off" element @@ -226,7 +270,7 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch searchIcon.addEventListener("mousedown", (e: Event) => e.stopPropagation()) document.body.addEventListener("mousedown", (e: Event) => if (document.body.contains(element)) { - document.body.removeChild(element) + handleEscape() } ) element.addEventListener("keydown", { @@ -245,8 +289,19 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch val selectedElement = resultsDiv.querySelector("[selected]") if selectedElement != null then { selectedElement.removeAttribute("selected") - val sibling = selectedElement.previousElementSibling - if sibling != null && sibling.classList.contains("scaladoc-searchbar-result") then { + def recur(elem: raw.Element): raw.Element = { + val prev = elem.previousElementSibling + if prev == null then null + else { + if !prev.classList.contains("hidden") && + prev.classList.contains("scaladoc-searchbar-row") && + (prev.hasAttribute("result") || prev.hasAttribute("loadmore")) + then prev + else recur(prev) + } + } + val sibling = recur(selectedElement) + if sibling != null then { sibling.setAttribute("selected", "") resultsDiv.scrollTop = sibling.asInstanceOf[html.Element].offsetTop - (2 * sibling.asInstanceOf[html.Element].clientHeight) } @@ -254,8 +309,19 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch } private def handleArrowDown() = { val selectedElement = resultsDiv.querySelector("[selected]") + def recur(elem: raw.Element): raw.Element = { + val next = elem.nextElementSibling + if next == null then null + else { + if !next.classList.contains("hidden") && + next.classList.contains("scaladoc-searchbar-row") && + (next.hasAttribute("result") || next.hasAttribute("loadmore")) + then next + else recur(next) + } + } if selectedElement != null then { - val sibling = selectedElement.nextElementSibling + val sibling = recur(selectedElement) if sibling != null then { selectedElement.removeAttribute("selected") sibling.setAttribute("selected", "") @@ -263,14 +329,10 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch } } else { val firstResult = resultsDiv.firstElementChild - if firstResult != null && firstResult.classList.contains("scaladoc-searchbar-result") then { - firstResult.setAttribute("selected", "") - resultsDiv.scrollTop = firstResult.asInstanceOf[html.Element].offsetTop - (2 * firstResult.asInstanceOf[html.Element].clientHeight) - } else if firstResult != null && firstResult.firstElementChild != null && firstResult.firstElementChild.nextElementSibling != null then { - // for Inkuire there is another wrapper to avoid displaying old results + the first (child) div is a loading animation wrapper | should be resolved in #12995 - val properFirstResult = firstResult.firstElementChild.nextElementSibling - properFirstResult.setAttribute("selected", "") - resultsDiv.scrollTop = properFirstResult.asInstanceOf[html.Element].offsetTop - (2 * properFirstResult.asInstanceOf[html.Element].clientHeight) + if firstResult != null then { + val toSelect = if firstResult.classList.contains("scaladoc-searchbar-row") && firstResult.hasAttribute("result") then firstResult else recur(firstResult) + toSelect.setAttribute("selected", "") + resultsDiv.scrollTop = toSelect.asInstanceOf[html.Element].offsetTop - (2 * toSelect.asInstanceOf[html.Element].clientHeight) } } } @@ -283,7 +345,7 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch private def handleEscape() = { // clear the search input and close the search input.value = "" - handleNewQuery("") + showHints() document.body.removeChild(rootDiv) } @@ -312,4 +374,59 @@ class SearchbarComponent(engine: SearchbarEngine, inkuireEngine: InkuireJSSearch } } - handleNewQuery("") + private case class ListRoot(elems: Seq[ListNode]) + private case class ListNode(value: String, nested: ListRoot) + + private def ul(nodes: ListNode*) = ListRoot(nodes) + private def li(s: String) = ListNode(s, ListRoot(Nil)) + private def li(s: String, nested: ListRoot) = ListNode(s, nested) + + private def renderList: ListRoot => Option[html.UList] = { + case ListRoot(Nil) => None + case ListRoot(nodes) => + val list = document.createElement("ul").asInstanceOf[html.UList] + nodes.foreach { + case ListNode(txt, nested) => + val li = document.createElement("li").asInstanceOf[html.LI] + li.innerHTML = txt + renderList(nested).foreach(li.appendChild) + list.appendChild(li) + } + Some(list) + } + + private def showHints() = { + def clearResults() = while (resultsDiv.hasChildNodes()) resultsDiv.removeChild(resultsDiv.lastChild) + val hintsDiv = document.createElement("div").asInstanceOf[html.Div] + hintsDiv.classList.add("searchbar-hints") + val icon = document.createElement("span").asInstanceOf[html.Span] + icon.classList.add("fas") + icon.classList.add("fa-lightbulb") + icon.classList.add("fa-5x") + val header = document.createElement("h1").asInstanceOf[html.Heading] + header.textContent = "A bunch of hints to make your life easier" + val listElements: ListRoot = ul( + li("Type a phrase to search members by name and static sites by title"), + li("Type abbreviations cC, caCa, camCa to search for camelCase"), + li( + "Type a function signature to search for members by signature using Inkuire", + ul( + li("Type String => Int to find String.size, String.toInt"), + li("Type String => String => String to find String.mkString, String.stripPrefix"), + li("Inkuire also finds field accessors. Type Some[A] => A to find Some.value"), + li("For more information about Inkuire see the documentation"), + li("The availability of this function depends on configuration used to generate Scaladoc") + ) + ) + ) + + val list = renderList(listElements).get + list.classList.add("searchbar-hints-list") + hintsDiv.appendChild(icon) + hintsDiv.appendChild(header) + hintsDiv.appendChild(list) + clearResults() + resultsDiv.appendChild(hintsDiv) + } + + showHints() diff --git a/scaladoc/resources/dotty_res/images/inkuire.svg b/scaladoc/resources/dotty_res/images/inkuire.svg new file mode 100644 index 000000000000..26c6399c7bdb --- /dev/null +++ b/scaladoc/resources/dotty_res/images/inkuire.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/scaladoc/resources/dotty_res/styles/colors.css b/scaladoc/resources/dotty_res/styles/colors.css index 1ef13486bb6c..f740bd5e3a00 100644 --- a/scaladoc/resources/dotty_res/styles/colors.css +++ b/scaladoc/resources/dotty_res/styles/colors.css @@ -86,7 +86,7 @@ --selected-fg: var(--blue900); --selected-bg: var(--blue200); - --shadow: var(--black); + --shadow: var(--grey500); --aside-warning-bg: var(--red100); } @@ -119,7 +119,7 @@ --link-hover-fg: var(--blue300); --link-sig-fg: var(--blue400); - --leftbar-bg: var(--grey930); + --leftbar-bg: var(--grey900); --leftbar-fg: var(--grey300); --leftbar-current-bg: var(--grey700); --leftbar-current-fg: var(--white); @@ -139,7 +139,7 @@ --tab-selected: var(--white); --tab-default: var(--grey300); - --shadow: var(--white); + --shadow: var(--grey500); --aside-warning-bg: var(--red800); } diff --git a/scaladoc/resources/dotty_res/styles/scalastyle.css b/scaladoc/resources/dotty_res/styles/scalastyle.css index 0c639a324f24..b0ef70209c71 100644 --- a/scaladoc/resources/dotty_res/styles/scalastyle.css +++ b/scaladoc/resources/dotty_res/styles/scalastyle.css @@ -560,9 +560,9 @@ footer .mode { .documentableElement .modifiers { display: table-cell; - min-width: 10em; - max-width: 10em; - width: 10em; + min-width: 10vw; + max-width: 10vw; + width: 10vw; overflow: hidden; text-align: right; white-space: nowrap; @@ -848,6 +848,10 @@ footer .mode { content: url("../images/method.svg") } +.micon.in { + content: url("../images/inkuire.svg") +} + #leftColumn .socials { display: none; } diff --git a/scaladoc/src/dotty/tools/scaladoc/renderers/Resources.scala b/scaladoc/src/dotty/tools/scaladoc/renderers/Resources.scala index b6e6c5adf1d0..4691b9e0027c 100644 --- a/scaladoc/src/dotty/tools/scaladoc/renderers/Resources.scala +++ b/scaladoc/src/dotty/tools/scaladoc/renderers/Resources.scala @@ -147,7 +147,7 @@ trait Resources(using ctx: DocContext) extends Locations, Writer: val descr = m.dri.asFileLocation def processMember(member: Member): Seq[JSON] = val signatureBuilder = ScalaSignatureProvider.rawSignature(member, InlineSignatureBuilder())().asInstanceOf[InlineSignatureBuilder] - val sig = Signature(Plain(s"${member.kind.name} "), Plain(member.name)) ++ signatureBuilder.names.reverse + val sig = Signature(Plain(member.name)) ++ signatureBuilder.names.reverse val entry = mkEntry(member.dri, member.name, flattenToText(sig), descr, member.kind.name) val children = member .membersBy(m => m.kind != Kind.Package && !m.kind.isInstanceOf[Classlike]) @@ -189,6 +189,7 @@ trait Resources(using ctx: DocContext) extends Locations, Writer: dottyRes("images/val.svg"), dottyRes("images/package.svg"), dottyRes("images/static.svg"), + dottyRes("images/inkuire.svg"), dottyRes("images/github-icon-black.png"), dottyRes("images/github-icon-white.png"), dottyRes("images/discord-icon-black.png"),