diff --git a/.github/workflows/scaladoc.yaml b/.github/workflows/scaladoc.yaml index f91c51b85c85..0457237c7554 100644 --- a/.github/workflows/scaladoc.yaml +++ b/.github/workflows/scaladoc.yaml @@ -35,6 +35,9 @@ jobs: with: java-version: 11 + - name: Compile and test scala3doc-js + run: ./project/scripts/sbt scaladoc-js/test + - name: Compile and test run: ./project/scripts/sbt scaladoc/test diff --git a/scaladoc-js/resources/scaladoc-searchbar.css b/scaladoc-js/resources/scaladoc-searchbar.css index 4aaaacb98288..035adbee6555 100644 --- a/scaladoc-js/resources/scaladoc-searchbar.css +++ b/scaladoc-js/resources/scaladoc-searchbar.css @@ -41,9 +41,10 @@ } #scaladoc-searchbar { - position: absolute; + position: fixed; top: 50px; right: 40px; + z-index: 5; width: calc(100% - 360px); box-shadow: 0 2px 16px 0 rgba(0, 42, 76, 0.15); font-size: 13px; @@ -71,6 +72,7 @@ } .scaladoc-searchbar-result { + background: white; line-height: 32px; padding-left: 10px; padding-right: 10px; @@ -80,7 +82,7 @@ margin-top: 10px; } -.scaladoc-searchbar-result:hover { +.scaladoc-searchbar-result[selected] { background-color: #d4edff; } diff --git a/scaladoc-js/src/Globals.scala b/scaladoc-js/src/Globals.scala index 919515e3d1f9..c04235dd607e 100644 --- a/scaladoc-js/src/Globals.scala +++ b/scaladoc-js/src/Globals.scala @@ -7,4 +7,11 @@ import scala.scalajs.js.annotation.JSGlobalScope @JSGlobalScope object Globals extends js.Object { val pathToRoot: String = js.native +} + +object StringUtils { + def createCamelCaseTokens(s: String): List[String] = + if s.isEmpty then List.empty + else if s.tail.indexWhere(_.isUpper) == -1 then List(s) + else List(s.take(s.tail.indexWhere(_.isUpper) + 1)) ++ createCamelCaseTokens(s.drop(s.tail.indexWhere(_.isUpper) + 1)) } \ No newline at end of file diff --git a/scaladoc-js/src/searchbar/PageEntry.scala b/scaladoc-js/src/searchbar/PageEntry.scala index 60812715d103..7e335a9b0e0b 100644 --- a/scaladoc-js/src/searchbar/PageEntry.scala +++ b/scaladoc-js/src/searchbar/PageEntry.scala @@ -15,18 +15,15 @@ case class PageEntry( description: String, location: String, shortName: String, - acronym: Option[String] + tokens: List[String] ) object PageEntry { - private def createAcronym(s: String): Option[String] = - s.headOption.map(firstLetter => firstLetter.toString ++ s.tail.filter(_.isUpper)) - def apply(jsObj: PageEntryJS): PageEntry = PageEntry( - jsObj.t, - jsObj.d, - jsObj.l, - jsObj.n.toLowerCase, - createAcronym(jsObj.n) - ) + jsObj.t, + jsObj.d, + jsObj.l, + jsObj.n.toLowerCase, + StringUtils.createCamelCaseTokens(jsObj.n) + ) } diff --git a/scaladoc-js/src/searchbar/SearchbarComponent.scala b/scaladoc-js/src/searchbar/SearchbarComponent.scala index 8d6d79c8ab49..20f7842ad749 100644 --- a/scaladoc-js/src/searchbar/SearchbarComponent.scala +++ b/scaladoc-js/src/searchbar/SearchbarComponent.scala @@ -22,6 +22,9 @@ class SearchbarComponent(val callback: (String) => List[PageEntry]): wrapper.appendChild(resultA) wrapper.appendChild(location) + wrapper.addEventListener("mouseover", { + case e: MouseEvent => handleHover(wrapper) + }) wrapper def handleNewQuery(query: String) = @@ -52,7 +55,10 @@ class SearchbarComponent(val callback: (String) => List[PageEntry]): if (document.body.contains(rootDiv)) { document.body.removeChild(rootDiv) } - else document.body.appendChild(rootDiv) + else { + document.body.appendChild(rootDiv) + input.focus() + } val element = createNestingDiv("search-content")( createNestingDiv("search-container")( @@ -69,6 +75,7 @@ class SearchbarComponent(val callback: (String) => List[PageEntry]): 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.autocomplete = "off" element private val resultsDiv: html.Div = @@ -94,9 +101,58 @@ class SearchbarComponent(val callback: (String) => List[PageEntry]): document.body.removeChild(element) } ) + element.addEventListener("keydown", { + case e: KeyboardEvent => + if e.keyCode == 40 then handleArrowDown() + else if e.keyCode == 38 then handleArrowUp() + else if e.keyCode == 13 then handleEnter() + }) element.id = "scaladoc-searchbar" element.appendChild(input) element.appendChild(resultsDiv) element + private def handleArrowUp() = { + val selectedElement = resultsDiv.querySelector("[selected]") + if selectedElement != null then { + selectedElement.removeAttribute("selected") + val sibling = selectedElement.previousElementSibling + if sibling != null then { + sibling.setAttribute("selected", "") + resultsDiv.scrollTop = sibling.asInstanceOf[html.Element].offsetTop - (2 * sibling.asInstanceOf[html.Element].clientHeight) + } + } + } + private def handleArrowDown() = { + val selectedElement = resultsDiv.querySelector("[selected]") + if selectedElement != null then { + val sibling = selectedElement.nextElementSibling + if sibling != null then { + selectedElement.removeAttribute("selected") + sibling.setAttribute("selected", "") + resultsDiv.scrollTop = sibling.asInstanceOf[html.Element].offsetTop - (2 * sibling.asInstanceOf[html.Element].clientHeight) + } + } else { + val firstResult = resultsDiv.firstElementChild + if firstResult != null then { + firstResult.setAttribute("selected", "") + resultsDiv.scrollTop = firstResult.asInstanceOf[html.Element].offsetTop - (2 * firstResult.asInstanceOf[html.Element].clientHeight) + } + } + } + private def handleEnter() = { + val selectedElement = resultsDiv.querySelector("[selected] a").asInstanceOf[html.Element] + if selectedElement != null then { + selectedElement.click() + } + } + + private def handleHover(elem: html.Element) = { + val selectedElement = resultsDiv.querySelector("[selected]") + if selectedElement != null then { + selectedElement.removeAttribute("selected") + } + elem.setAttribute("selected","") + } + handleNewQuery("") diff --git a/scaladoc-js/src/searchbar/engine/Matchers.scala b/scaladoc-js/src/searchbar/engine/Matchers.scala index a933a3f88c23..ceca8afd357e 100644 --- a/scaladoc-js/src/searchbar/engine/Matchers.scala +++ b/scaladoc-js/src/searchbar/engine/Matchers.scala @@ -1,23 +1,24 @@ package dotty.tools.scaladoc -enum Matchers extends Function1[PageEntry, Int]: - case ByName(query: String) - case ByKind(kind: String) +sealed trait Matchers extends Function1[PageEntry, Int] - def apply(p: PageEntry): Int = this match { - case ByName(query) => { - val nameOption = Option(p.shortName) - val acronym = p.acronym - //Edge case for empty query string - if query == "" then 1 - else { - val results = List( - nameOption.filter(_.contains(query.toLowerCase)).fold(-1)(_.size - query.size), - acronym.filter(_.contains(query)).fold(-1)(_.size - query.size + 1) - ) - if results.forall(_ == -1) then -1 else results.filter(_ != -1).min - } +case class ByName(query: String) extends Matchers: + val tokens = StringUtils.createCamelCaseTokens(query) + def apply(p: PageEntry): Int = { + val nameOption = Option(p.shortName.toLowerCase) + //Edge case for empty query string + if query == "" then 1 + else { + val results = List( + nameOption.filter(_.contains(query.toLowerCase)).fold(-1)(_.size - query.size), + if p.tokens.size >= tokens.size && p.tokens.zip(tokens).forall( (token, query) => token.startsWith(query)) + then p.tokens.size - tokens.size + 1 + else -1 + //acronym.filter(_.contains(query)).fold(-1)(_.size - query.size + 1) + ) + if results.forall(_ == -1) then -1 else results.filter(_ != -1).min } - case ByKind(kind) => p.fullName.split(" ").headOption.filter(_.equalsIgnoreCase(kind)).fold(-1)(_ => 1) } +case class ByKind(kind: String) extends Matchers: + def apply(p: PageEntry): Int = p.fullName.split(" ").headOption.filter(_.equalsIgnoreCase(kind)).fold(-1)(_ => 1) diff --git a/scaladoc-js/src/searchbar/engine/QueryParser.scala b/scaladoc-js/src/searchbar/engine/QueryParser.scala index bb6dcaa3b1c9..f5996484188c 100644 --- a/scaladoc-js/src/searchbar/engine/QueryParser.scala +++ b/scaladoc-js/src/searchbar/engine/QueryParser.scala @@ -21,8 +21,8 @@ class QueryParser: val escapedRegex = raw"`(.*)`".r def parse(query: String): List[Matchers] = query match { - case escapedRegex(rest) => List(Matchers.ByName(rest)) - case kindRegex(kind, rest) => List(Matchers.ByKind(kind)) ++ parse(rest) - case restRegex(name) => List(Matchers.ByName(name)) + case escapedRegex(rest) => List(ByName(rest)) + case kindRegex(kind, rest) => List(ByKind(kind)) ++ parse(rest) + case restRegex(name) => List(ByName(name)) case _ => List() } \ No newline at end of file diff --git a/scaladoc-js/test/dotty/dokka/MatchersTest.scala b/scaladoc-js/test/dotty/dokka/MatchersTest.scala new file mode 100644 index 000000000000..deaa188a6c7c --- /dev/null +++ b/scaladoc-js/test/dotty/dokka/MatchersTest.scala @@ -0,0 +1,79 @@ +package dotty.tools.scaladoc + +import org.junit.{Test, Assert} +import org.junit.Assert._ + +class MatchersTest: + private val kinds = Seq( + "class", + "trait", + "enum", + "object", + "def", + "val", + "var", + "package", + "given", + "type" + ) + private val names = Seq( + "NullPointerException", + "NPException", + "Seq", + "SeqOps", + "writeBytes", + "lessOrEqual", + "testFuzzySearch1", + "testF", + "testFS" + ) + private val pages = for { + kind <- kinds + name <- names + } yield PageEntry( + s"$kind $name", + "", + "", + s"$name", + StringUtils.createCamelCaseTokens(name) + ) + + private def result(matchers: List[Matchers]) = { + pages.map { p => + p -> matchers.map(_(p)) + }.filterNot { (page, results) => + results.exists(_ == -1) + }.map((page, results) => page) + } + + @Test + def testByKind = kinds.foreach { kind => + val res = result(List(ByKind(kind))) + val expected = pages.filter(p => p.fullName.startsWith(kind)).toSet + assertEquals( + s"Matchers test error: for kind: $kind should match $expected but matched $res", + expected, + res.toSet + ) + } + + private def byNameTestCase(query: String, expectedMatch: String*) = expectedMatch.foreach { expMatch => + assertTrue( + s"Matchers test error: for query: $query expected $expMatch", + result(List(ByName(query))).exists(p => p.shortName.contains(expMatch)) + ) + } + + @Test + def testByName = { + names.foreach(n => byNameTestCase(n, n)) + byNameTestCase("NPE", "NPException", "NullPointerException") + byNameTestCase("NullPE", "NullPointerException") + byNameTestCase("tFuzzS", "testFuzzySearch1") + byNameTestCase("SO", "SeqOps") + byNameTestCase("teFS", "testFS") + byNameTestCase("writeBy", "writeBytes") + byNameTestCase("seQ", "Seq") + byNameTestCase("lOrEqu", "lessOrEqual") + byNameTestCase("teF", "testFS", "testF") + } \ No newline at end of file diff --git a/scaladoc-js/test/dotty/dokka/QueryParserTest.scala b/scaladoc-js/test/dotty/dokka/QueryParserTest.scala new file mode 100644 index 000000000000..b89c7502714f --- /dev/null +++ b/scaladoc-js/test/dotty/dokka/QueryParserTest.scala @@ -0,0 +1,35 @@ +package dotty.tools.scaladoc + +import org.junit.{Test, Assert} +import org.junit.Assert._ + +class QueryParserTest: + val queryParser = QueryParser() + val kinds = Seq( + "class", + "trait", + "enum", + "object", + "def", + "val", + "var", + "package", + "given", + "type" + ) + private def testCase(query: String, result: List[Matchers]) = { + val parsed = queryParser.parse(query) + assertEquals( + s"Query parser test error: for query: $query expected $result but found $parsed", + parsed, + result + ) + } + + @Test + def queryParserTests() = { + kinds.foreach(k => testCase(s"$k ", List(ByKind(k), ByName("")))) + testCase("trait", List(ByName("trait"))) + testCase("trait A", List(ByKind("trait"), ByName("A"))) + testCase("`trait A`", List(ByName("trait A"))) + } \ No newline at end of file