Project

General

Profile

Feature #4301 » redmine-7.0.1-wiki-sidebar-toc.patch

Minoru Nagata, 2026-10-22 15:07

View differences:

app/assets/stylesheets/application.css
2019 2019
}
2020 2020
div.wiki ul.toc a:hover { color: var(--oc-red-9); text-decoration: underline;}
2021 2021

  
2022
.wiki-toc-sidebar ul.toc a.is-active {
2023
  background-color: var(--oc-blue-1);
2024
  color: var(--oc-blue-9);
2025
  border-radius: 2px;
2026
  box-shadow: 0 0 0 2px var(--oc-blue-1);
2027
}
2028

  
2029
/* Sidebar TOC: keep the rules scoped after the sidebar's list styles. */
2030
.wiki-toc-sidebar { clear: both; overflow-wrap: anywhere; }
2031
.wiki-toc-sidebar-controls { display: flex; justify-content: flex-end; flex-shrink: 0; gap: 4px; padding: 4px 8px; }
2032
.wiki-toc-sidebar .wiki-toc-button {
2033
  background: none;
2034
  border: 0;
2035
  box-shadow: none;
2036
  color: var(--oc-blue-8);
2037
  cursor: pointer;
2038
  font: inherit;
2039
  min-inline-size: 28px;
2040
  min-block-size: 28px;
2041
  padding: 2px 6px;
2042
}
2043
.wiki-toc-sidebar .wiki-toc-button:hover { color: var(--oc-red-9); }
2044
.wiki-toc-sidebar .wiki-toc-button:focus-visible { outline: 2px solid var(--oc-blue-8); outline-offset: 1px; }
2045
.wiki-toc-sidebar .wiki-toc-button svg { vertical-align: middle; }
2046
.flyout-menu .wiki-toc-sidebar .wiki-toc-button { color: inherit; }
2047
#sidebar .wiki-toc-sidebar ul.toc,
2048
.flyout-menu .wiki-toc-sidebar ul.toc { display: block; margin: 0; padding: 8px; }
2049
#sidebar .wiki-toc-sidebar ul.toc ul,
2050
.flyout-menu .wiki-toc-sidebar ul.toc ul { margin: 0; padding: 0; }
2051
#sidebar .wiki-toc-sidebar ul.toc li,
2052
.flyout-menu .wiki-toc-sidebar ul.toc li { list-style-type: none; }
2053
#sidebar .wiki-toc-sidebar ul.toc li li,
2054
.flyout-menu .wiki-toc-sidebar ul.toc li li { margin-inline-start: 1em; }
2055

  
2056
@media screen and (min-width: 900px) {
2057
  #wrapper:has(.wiki-toc-sidebar) { overflow: clip; }
2058
  #sidebar:has(.wiki-toc-sidebar) { display: flex; flex-direction: column; }
2059
  #sidebar:has(.wiki-toc-sidebar) #sidebar-wrapper { flex: 1; min-block-size: 0; }
2060
  .wiki-toc-sidebar {
2061
    display: flex;
2062
    flex-direction: column;
2063
    position: sticky;
2064
    top: 10px;
2065
    max-block-size: calc(100vh - 20px);
2066
    margin-block-end: 14px;
2067
  }
2068
  .wiki-toc-sidebar-content { min-block-size: 0; overflow-y: auto; }
2069
}
2070

  
2022 2071
a.wiki-anchor { display: none; margin-inline-start: 6px; text-decoration: none; }
2023 2072
a.wiki-anchor:hover { color: var(--oc-gray-5) !important; text-decoration: none; }
2024 2073
h1:hover a.wiki-anchor, h2:hover a.wiki-anchor, h3:hover a.wiki-anchor, h4:hover a.wiki-anchor, h5:hover a.wiki-anchor, h6:hover a.wiki-anchor { display: inline; color: var(--oc-gray-4); }
app/helpers/application_helper.rb
941 941
  # * with an object and one of its attribute: textilizable(issue, :description, options)
942 942
  def textilizable(*args)
943 943
    options = args.extract_options!
944
    @wiki_toc_sidebar = nil if options[:toc_sidebar]
944 945
    case args.size
945 946
    when 1
946 947
      obj = options[:object]
......
979 980
    parse_sections(text, project, obj, attr, only_path, options)
980 981
    parse_headings(text, project, obj, attr, only_path, options)
981 982

  
982
    if @parsed_headings.any?
983
      replace_toc(text, @parsed_headings)
983
    if @parsed_headings.any? || options[:toc_sidebar]
984
      replace_toc(text, @parsed_headings, options)
984 985
    end
985 986

  
986 987
    text.html_safe
......
1497 1498
    end
1498 1499
  end
1499 1500

  
1500
  TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc\}\}<\/p>/i unless const_defined?(:TOC_RE)
1501
  TOC_RE = /<p>\{\{((<|&lt;)|(>|&gt;))?toc(\(sidebar\))?\}\}<\/p>/i unless const_defined?(:TOC_RE)
1501 1502

  
1502 1503
  # Renders the TOC with given headings
1503
  def replace_toc(text, headings)
1504
    text.gsub!(TOC_RE) do
1505
      left_align, right_align = $2, $3
1504
  def replace_toc(text, headings, options={})
1505
    has_headings = headings.any?
1506
    text.gsub!(TOC_RE) do |match|
1507
      left_align, right_align, sidebar = $2, $3, $4
1508
      next match if sidebar && (!options[:toc_sidebar] || left_align || right_align)
1509
      next match if !sidebar && !has_headings
1510

  
1506 1511
      # Keep only the 4 first levels
1507 1512
      headings = headings.select{|level, anchor, item| level <= 4}
1508 1513
      if headings.empty?
......
1523 1528
          elsif started
1524 1529
            out << '</li><li>'
1525 1530
          end
1526
          out << "<a href=\"##{anchor}\">#{item}</a>"
1531
          label = sidebar ? CGI.escapeHTML(item) : item
1532
          out << "<a href=\"##{anchor}\">#{label}</a>"
1527 1533
          current = level
1528 1534
          started = true
1529 1535
        end
1530 1536
        out << '</li></ul>' * (current - root)
1531 1537
        out << '</li></ul>'
1538
        if sidebar
1539
          # Only markup built here, with escaped labels, is trusted.
1540
          @wiki_toc_sidebar ||= out.html_safe
1541
          ''
1542
        else
1543
          out
1544
        end
1532 1545
      end
1533 1546
    end
1534 1547
  end
app/javascript/controllers/wiki_sidebar_toc_controller.js
1
import { Controller } from "@hotwired/stimulus";
2

  
3
export default class extends Controller {
4
  scrollToTop() {
5
    window.scrollTo({ top: 0, behavior: "instant" });
6
  }
7

  
8
  scrollToBottom() {
9
    window.scrollTo({ top: document.scrollingElement.scrollHeight, behavior: "instant" });
10
  }
11

  
12
  connect() {
13
    const content = document.querySelector("#content .wiki-page");
14
    if (!content) return;
15

  
16
    const anchors = new Map(
17
      Array.from(content.querySelectorAll("a[name], [id]"), anchor =>
18
        [anchor.getAttribute("name") || anchor.id, anchor])
19
    );
20
    this.items = Array.from(this.element.querySelectorAll('ul.toc a[href^="#"]'))
21
      .map(link => {
22
        const fragment = link.getAttribute("href").slice(1);
23
        let name;
24
        try { name = decodeURIComponent(fragment); } catch { name = fragment; }
25
        return { link, anchor: anchors.get(fragment) || anchors.get(name) };
26
      }).filter(item => item.anchor);
27
    this.scheduleUpdate = () => {
28
      if (this.frame) return;
29
      this.frame = requestAnimationFrame(() => {
30
        this.frame = null;
31
        this.updateCurrentHeading();
32
      });
33
    };
34
    window.addEventListener("scroll", this.scheduleUpdate, { passive: true });
35
    window.addEventListener("resize", this.scheduleUpdate);
36
    window.addEventListener("hashchange", this.scheduleUpdate);
37
    this.scheduleUpdate();
38
  }
39

  
40
  disconnect() {
41
    window.removeEventListener("scroll", this.scheduleUpdate);
42
    window.removeEventListener("resize", this.scheduleUpdate);
43
    window.removeEventListener("hashchange", this.scheduleUpdate);
44
    if (this.frame) cancelAnimationFrame(this.frame);
45
    this.frame = null;
46
    this.items?.forEach(({ link }) => {
47
      link.classList.remove("is-active");
48
      link.removeAttribute("aria-current");
49
    });
50
  }
51

  
52
  updateCurrentHeading() {
53
    let active = null;
54
    for (const item of this.items) {
55
      if (item.anchor.getBoundingClientRect().top <= 10) active = item.link;
56
    }
57
    for (const { link } of this.items) {
58
      link.classList.toggle("is-active", link === active);
59
      if (link === active) link.setAttribute("aria-current", "location");
60
      else link.removeAttribute("aria-current");
61
    }
62
  }
63
}
app/views/wiki/_content.html.erb
1 1
<div class="wiki wiki-page">
2 2
  <%= textilizable content, :text, :attachments => content.page.attachments,
3
        :toc_sidebar => local_assigns.fetch(:toc_sidebar, false),
3 4
        :edit_section_links => (@sections_editable && {:controller => 'wiki', :action => 'edit', :project_id => @page.project, :id => @page.title}) %>
4 5
</div>
app/views/wiki/_sidebar.html.erb
12 12
  </div>
13 13
<% end -%>
14 14

  
15
<% if @wiki_toc_sidebar.present? -%>
16
  <div class="wiki wiki-toc-sidebar" data-controller="wiki-sidebar-toc">
17
    <div class="wiki-toc-sidebar-controls">
18
      <%= button_tag :type => 'button', :class => 'wiki-toc-button', :title => l(:button_scroll_to_top),
19
                     :aria => {:label => l(:button_scroll_to_top)},
20
                     :data => {:action => 'wiki-sidebar-toc#scrollToTop'} do %>
21
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
22
             stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
23
          <path d="m7 13 5-5 5 5m-10 5 5-5 5 5" />
24
        </svg>
25
      <% end %>
26
      <%= button_tag :type => 'button', :class => 'wiki-toc-button', :title => l(:button_scroll_to_bottom),
27
                     :aria => {:label => l(:button_scroll_to_bottom)},
28
                     :data => {:action => 'wiki-sidebar-toc#scrollToBottom'} do %>
29
        <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor"
30
             stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
31
          <path d="m7 6 5 5 5-5m-10 5 5 5 5-5" />
32
        </svg>
33
      <% end %>
34
    </div>
35
    <div class="wiki-toc-sidebar-content">
36
      <%= @wiki_toc_sidebar %>
37
    </div>
38
  </div>
39
<% end -%>
40

  
15 41
<h3><%= l(:label_wiki) %></h3>
16 42
<ul>
17 43
  <li><%= link_to(l(:field_start_page), {:action => 'show', :id => nil}) %></li>
app/views/wiki/show.html.erb
59 59
    <hr />
60 60
<% end %>
61 61

  
62
<%= render(:partial => "wiki/content", :locals => {:content => @content}) %>
62
<%= render(:partial => "wiki/content", :locals => {:content => @content, :toc_sidebar => true}) %>
63 63

  
64 64
<fieldset class="collapsible collapsed hide-when-print">
65 65
  <legend onclick="toggleFieldset(this);" class="icon icon-collapsed">
config/locales/en.yml
1 1
en:
2
  button_scroll_to_top: Scroll to top
3
  button_scroll_to_bottom: Scroll to bottom
2 4
  # Text direction: Left-to-Right (ltr) or Right-to-Left (rtl)
3 5
  direction: ltr
4 6
  date:
config/locales/ja.yml
3 3
# AR error messages are basically taken from Ruby-GetText-Package. Thanks to Masao Mutoh.
4 4

  
5 5
ja:
6
  button_scroll_to_top: 最上部へ
7
  button_scroll_to_bottom: 最下部へ
6 8
  direction: ltr
7 9
  date:
8 10
    formats:
test/functional/issues_controller_test.rb
26 26
    User.current = nil
27 27
  end
28 28

  
29
  def test_sidebar_toc_remains_literal_outside_wiki
30
    with_settings :text_formatting => 'textile' do
31
      record = Issue.find(1)
32
      record.update!(:description => "{{toc(sidebar)}}\n\nh1. Heading")
33
      get :show, :params => {:id => record.id}
34
      assert_response :success
35
      assert_includes response.body, '{{toc(sidebar)}}'
36
      assert_select '.wiki-toc-sidebar', 0
37
    end
38
  end
39

  
29 40
  def test_index
30 41
    with_settings :default_language => "en" do
31 42
      get :index
test/functional/news_controller_test.rb
24 24
    User.current = nil
25 25
  end
26 26

  
27
  def test_sidebar_toc_remains_literal_outside_wiki
28
    with_settings :text_formatting => 'textile' do
29
      record = News.find(1)
30
      record.update!(:description => "{{toc(sidebar)}}\n\nh1. Heading")
31
      get :show, :params => {:id => record.id}
32
      assert_response :success
33
      assert_includes response.body, '{{toc(sidebar)}}'
34
      assert_select '.wiki-toc-sidebar', 0
35
    end
36
  end
37

  
27 38
  def test_index
28 39
    get :index
29 40
    assert_response :success
test/functional/previews_controller_test.rb
20 20
require_relative '../test_helper'
21 21

  
22 22
class PreviewsControllerTest < Redmine::ControllerTest
23
  def test_sidebar_toc_remains_literal_in_issue_preview
24
    @request.session[:user_id] = 2
25
    with_settings :text_formatting => 'textile' do
26
      post :issue, :params => {:project_id => 1, :text => "{{toc(sidebar)}}\n\nh1. Heading"}
27
      assert_response :success
28
      assert_includes response.body, '{{toc(sidebar)}}'
29
      assert_select '.wiki-toc-sidebar', 0
30
    end
31
  end
32

  
23 33
  def test_preview_new_issue_description
24 34
    @request.session[:user_id] = 2
25 35
    post(
test/functional/versions_controller_test.rb
25 25
    User.current = nil
26 26
  end
27 27

  
28
  def test_sidebar_toc_remains_literal_outside_wiki
29
    with_settings :text_formatting => 'textile' do
30
      record = Version.find(3)
31
      record.update!(:description => "{{toc(sidebar)}}\n\nh1. Heading")
32
      get :show, :params => {:id => record.id}
33
      assert_response :success
34
      assert_includes response.body, '{{toc(sidebar)}}'
35
      assert_select '.wiki-toc-sidebar', 0
36
      get :index, :params => {:project_id => record.project_id}
37
      assert_response :success
38
      assert_includes response.body, '{{toc(sidebar)}}'
39
      assert_select '.wiki-toc-sidebar', 0
40
    end
41
  end
42

  
28 43
  def test_index
29 44
    get :index, :params => {:project_id => 1}
30 45
    assert_response :success
test/functional/wiki_controller_test.rb
24 24
    User.current = nil
25 25
  end
26 26

  
27
  def test_show_sidebar_toc_between_sidebar_and_wiki_menu
28
    @request.session[:user_id] = 2
29
    with_settings :text_formatting => 'textile' do
30
      page = WikiPage.find(1)
31
      page.content.update!(:text => "{{toc(sidebar)}}\n\nh1. Main\n\nh2. Child")
32
      page.add_watcher User.find(2)
33
      sidebar = page.wiki.pages.build(:title => 'Sidebar')
34
      sidebar.content = WikiContent.new(:text => "h3. Sidebar\n\n{{toc(sidebar)}}")
35
      sidebar.save!
36
      get :show, :params => {:project_id => 1, :id => page.title}
37
      assert_response :success
38
      assert_select '#sidebar-wrapper > .wiki + .wiki-toc-sidebar + h3', 1
39
      assert_select '.wiki-toc-sidebar a[href="#Main"]', 1
40
      assert_select '.wiki-toc-sidebar li li a', :text => 'Child'
41
      assert_select '#content .wiki-page', :text => /\{\{toc\(sidebar\)\}\}/, :count => 0
42
      assert_select '#watchers', 1
43
    end
44
  end
45

  
46
  def test_show_sidebar_toc_for_previous_version_without_sidebar_page
47
    with_settings :text_formatting => 'textile' do
48
      page = WikiPage.find(1)
49
      page.content.update!(:text => "{{toc(sidebar)}}\n\nh1. Previous")
50
      version = page.content.version
51
      page.content.update!(:text => 'New content')
52
      get :show, :params => {:project_id => 1, :id => page.title, :version => version}
53
      assert_response :success
54
      assert_select '#sidebar-wrapper > .wiki-toc-sidebar + h3', 1
55
      assert_select '.wiki-toc-sidebar a[href="#Previous"]', 1
56
    end
57
  end
58

  
59
  def test_show_without_sidebar_toc_or_headings_has_no_container
60
    with_settings :text_formatting => 'textile' do
61
      page = WikiPage.find(1)
62
      ['h1. Heading', '{{toc(sidebar)}}'].each do |text|
63
        page.content.update!(:text => text)
64
        get :show, :params => {:project_id => 1, :id => page.title}
65
        assert_response :success
66
        assert_select '.wiki-toc-sidebar', 0
67
      end
68
    end
69
  end
70

  
71
  def test_sidebar_toc_is_not_enabled_for_html_export
72
    Role.anonymous.add_permission! :export_wiki_pages
73
    with_settings :text_formatting => 'textile' do
74
      page = WikiPage.find(1)
75
      page.content.update!(:text => "{{toc(sidebar)}}\n\nh1. Heading")
76
      get :show, :params => {:project_id => 1, :id => page.title, :format => 'html'}
77
      assert_response :success
78
      assert_includes response.body, '{{toc(sidebar)}}'
79
      assert_select '.wiki-toc-sidebar', 0
80
    end
81
  end
82

  
27 83
  def test_show_start_page
28 84
    with_settings :text_formatting => 'textile' do
29 85
      get :show, :params => {:project_id => 'ecookbook'}
test/helpers/application_helper_test.rb
1502 1502
    end
1503 1503
  end
1504 1504

  
1505
  def test_sidebar_toc_with_both_formatters
1506
    {'textile' => "h1. 日本語 & Heading\n\nh2. Child\n\nh1. 日本語 & Heading",
1507
     'common_mark' => "# 日本語 & Heading\n\n## Child\n\n# 日本語 & Heading"}.each do |format, headings|
1508
      with_settings :text_formatting => format do
1509
        result = textilizable("{{toc(sidebar)}}\n\n{{toc(sidebar)}}\n\n{{toc}}\n\n#{headings}", :toc_sidebar => true)
1510
        assert_not_includes result, '{{toc(sidebar)}}'
1511
        assert_select_in result, 'ul.toc', 1
1512
        assert @wiki_toc_sidebar.html_safe?
1513
        assert_select_in @wiki_toc_sidebar, 'ul.toc', 1
1514
        assert_select_in @wiki_toc_sidebar, 'li li a', :text => 'Child'
1515
        toc = Nokogiri::HTML.fragment(@wiki_toc_sidebar)
1516
        anchors = Nokogiri::HTML.fragment(result).css('a[name]').pluck('name')
1517
        assert_equal anchors, toc.css('a').map {|a| a['href'].delete_prefix('#')}
1518
        assert_equal 3, anchors.uniq.size
1519
      end
1520
    end
1521
  end
1522

  
1523
  def test_sidebar_toc_is_opt_in_and_does_not_change_inline_alignment
1524
    with_settings :text_formatting => 'textile' do
1525
      result = textilizable("{{toc(sidebar)}}\n\nh1. Heading")
1526
      assert_includes result, '{{toc(sidebar)}}'
1527
      assert_nil @wiki_toc_sidebar
1528
      result = textilizable("{{toc(sidebar)}}\n\n{{<toc}}\n\n{{>toc}}\n\nh1. Heading", :toc_sidebar => true)
1529
      assert_select_in result, 'ul.toc.left', 1
1530
      assert_select_in result, 'ul.toc.right', 1
1531
    end
1532
  end
1533

  
1534
  def test_sidebar_toc_resets_only_for_enabled_rendering_including_blank_content
1535
    with_settings :text_formatting => 'textile' do
1536
      textilizable("{{toc(sidebar)}}\n\nh1. Main", :toc_sidebar => true)
1537
      saved = @wiki_toc_sidebar
1538
      textilizable("{{toc(sidebar)}}\n\nh1. Sidebar")
1539
      assert_equal saved, @wiki_toc_sidebar
1540
      textilizable('', :toc_sidebar => true)
1541
      assert_nil @wiki_toc_sidebar
1542
      result = textilizable("{{toc(sidebar)}}\n\n{{toc}}", :toc_sidebar => true)
1543
      assert_includes result, '{{toc}}'
1544
      assert_not_includes result, '{{toc(sidebar)}}'
1545
      assert_nil @wiki_toc_sidebar
1546
      result = textilizable("{{toc(sidebar)}}\n\nh5. Too deep", :toc_sidebar => true)
1547
      assert_not_includes result, '{{toc(sidebar)}}'
1548
      assert_nil @wiki_toc_sidebar
1549
    end
1550
  end
1551

  
1552
  def test_sidebar_toc_preserves_code_and_escapes_heading_content
1553
    {'textile' => "<pre>{{toc(sidebar)}}</pre>\n\nh1. &lt;script&gt;alert(1)&lt;/script&gt;",
1554
     'common_mark' => "```\n{{toc(sidebar)}}\n```\n\n# &lt;script&gt;alert(1)&lt;/script&gt;"}.each do |format, text|
1555
      with_settings :text_formatting => format do
1556
        result = textilizable(text, :toc_sidebar => true)
1557
        assert_includes result, '{{toc(sidebar)}}'
1558
        assert_nil @wiki_toc_sidebar
1559
        textilizable("{{toc(sidebar)}}\n\n#{text}", :toc_sidebar => true)
1560
        assert_select_in @wiki_toc_sidebar, 'script', 0
1561
        assert_select_in @wiki_toc_sidebar, 'a', :text => '<script>alert(1)</script>'
1562
      end
1563
    end
1564
  end
1565

  
1566
  def test_sidebar_toc_include_preserves_existing_heading_collection
1567
    with_settings :text_formatting => 'textile' do
1568
      @project = Project.find(1)
1569
      page = @project.wiki.pages.find_by!(:title => 'Another_page')
1570
      page.content.update!(:text => "{{toc(sidebar)}}\n\nh1. Included")
1571
      result = textilizable("{{include(Another_page)}}\n\n{{toc(sidebar)}}\n\nh1. Main", :toc_sidebar => true)
1572
      assert_select_in @wiki_toc_sidebar, 'a', :text => 'Main'
1573
      anchors = Nokogiri::HTML.fragment(result).css('a[name]').pluck('name')
1574
      links = Nokogiri::HTML.fragment(@wiki_toc_sidebar).css('a').map {|a| a['href'].delete_prefix('#')}
1575
      assert_equal anchors, links
1576
    end
1577
  end
1578

  
1505 1579
  def test_toc_with_textile_formatting_should_be_parsed
1506 1580
    with_settings :text_formatting => 'textile' do
1507 1581
      assert_select_in textilizable("{{toc}}\n\nh1. Heading"), 'ul.toc li', :text => 'Heading'
test/system/wiki_sidebar_toc_test.rb
1
# frozen_string_literal: true
2

  
3
require_relative '../application_system_test_case'
4

  
5
class WikiSidebarTocSystemTest < ApplicationSystemTestCase
6
  def setup
7
    super
8
    @original_formatting = Setting.text_formatting
9
    Setting.text_formatting = 'textile'
10
    wiki_page = WikiPage.find(1)
11
    text = +"{{toc(sidebar)}}\n\nIntroduction\n\n"
12
    text << (1..60).map {|i| "h#{i.between?(2, 3) ? i : 1}. Heading #{i}\n\n" + ("Paragraph content.\n\n" * 20)}.join
13
    wiki_page.content.update!(:text => text)
14
    page.current_window.resize_to(1280, 900)
15
    visit '/projects/ecookbook/wiki/CookBook_documentation'
16
    assert_selector '.wiki-toc-sidebar[data-controller="wiki-sidebar-toc"]'
17
  end
18

  
19
  def teardown
20
    Setting.text_formatting = @original_formatting
21
    super
22
  end
23

  
24
  def test_current_heading_follows_scroll_click_and_back_scroll
25
    assert_no_selector '.wiki-toc-sidebar a.is-active'
26
    [1, 2, 10, 1].each do |number|
27
      page.execute_script("document.getElementsByName(arguments[0])[0].scrollIntoView()", "Heading-#{number}")
28
      assert_selector ".wiki-toc-sidebar a.is-active[aria-current='location'][href='#Heading-#{number}']"
29
      assert_selector '.wiki-toc-sidebar a.is-active', :count => 1
30
    end
31
    find('.wiki-toc-sidebar a[href="#Heading-3"]').click
32
    assert_selector '.wiki-toc-sidebar a.is-active[href="#Heading-3"]'
33
    page.execute_script('window.scrollTo(0, 0)')
34
    assert_no_selector '.wiki-toc-sidebar a.is-active'
35
  end
36

  
37
  def test_sticky_internal_scroll_and_mobile_breakpoint
38
    page.current_window.resize_to(1280, 800)
39
    page.execute_script('window.scrollTo(0, 1200)')
40
    assert_selector '.wiki-toc-sidebar a.is-active'
41
    metrics = page.evaluate_script(<<~JS)
42
      (() => {
43
        const toc = document.querySelector('.wiki-toc-sidebar');
44
        const content = toc.querySelector('.wiki-toc-sidebar-content');
45
        return {
46
          position: getComputedStyle(toc).position,
47
          top: toc.getBoundingClientRect().top,
48
          height: toc.clientHeight, contentHeight: content.clientHeight, scrollHeight: content.scrollHeight,
49
          viewport: innerHeight
50
        };
51
      })()
52
    JS
53
    assert_equal 'sticky', metrics['position']
54
    assert_in_delta 10, metrics['top'], 2
55
    assert_operator metrics['scrollHeight'], :>, metrics['contentHeight']
56
    assert_operator metrics['height'], :<=, metrics['viewport'] - 20
57
    controls_top = page.evaluate_script("document.querySelector('.wiki-toc-sidebar-controls').getBoundingClientRect().top")
58
    page.execute_script("document.querySelector('.wiki-toc-sidebar-content').scrollTop = 150")
59
    assert_operator page.evaluate_script("document.querySelector('.wiki-toc-sidebar-content').scrollTop"), :>, 0
60
    assert_in_delta controls_top, page.evaluate_script("document.querySelector('.wiki-toc-sidebar-controls').getBoundingClientRect().top"), 1
61
    page.current_window.resize_to(900, 800)
62
    assert_equal 'sticky', page.evaluate_script("getComputedStyle(document.querySelector('.wiki-toc-sidebar')).position")
63
    page.current_window.resize_to(899, 800)
64
    assert_equal 'static', page.evaluate_script("getComputedStyle(document.querySelector('.wiki-toc-sidebar')).position")
65
    assert_equal 'none', page.evaluate_script("getComputedStyle(document.querySelector('.wiki-toc-sidebar')).maxBlockSize")
66
    assert_selector '.flyout-menu .wiki-toc-sidebar', :visible => :all
67
  end
68

  
69
  def test_scroll_controls_reach_page_ends_and_keep_the_fragment
70
    page.execute_script("window.location.hash = 'Heading-2'")
71
    assert_selector '.wiki-toc-sidebar a.is-active[href="#Heading-2"]'
72
    find('.wiki-toc-sidebar-controls [data-action="wiki-sidebar-toc#scrollToBottom"]').click
73
    assert_in_delta page.evaluate_script('document.scrollingElement.scrollHeight - innerHeight'),
74
                    page.evaluate_script('scrollY'), 1
75
    find('.wiki-toc-sidebar-controls [data-action="wiki-sidebar-toc#scrollToTop"]').click
76
    assert_in_delta 0, page.evaluate_script('scrollY'), 1
77
    assert_equal '#Heading-2', page.evaluate_script('location.hash')
78
  end
79

  
80
  def test_all_levels_remain_visible_without_toggle_padding
81
    [1, 2, 3].each do |number|
82
      assert_selector ".wiki-toc-sidebar a[href='#Heading-#{number}']"
83
    end
84
    metrics = page.evaluate_script(<<~JS)
85
      (() => {
86
        const links = [1, 2, 3].map(n =>
87
          document.querySelector('.wiki-toc-sidebar a[href="#Heading-' + n + '"]'));
88
        return links.map(link => ({
89
          left: link.getBoundingClientRect().left,
90
          font: parseFloat(getComputedStyle(link).fontSize),
91
          padding: parseFloat(getComputedStyle(link.parentElement).paddingInlineStart)
92
        }));
93
      })()
94
    JS
95
    metrics.each {|metric| assert_in_delta 0, metric['padding'], 0.1}
96
    [1, 2].each do |index|
97
      assert_in_delta metrics[index]['font'], metrics[index]['left'] - metrics[index - 1]['left'], 1
98
    end
99
  end
100
end
(3-3/3)