使用Stimulus和IntersectionObserver的神奇响应表(详细教程)

225 阅读6分钟

这是与Pascal Laliberté的合作,他是《谦虚的JS作品》的作者,这是一本为那些想写适度的JavaScript,然后专注于建立一个应用程序的所有其他东西的人而写的简短的在线书籍。

你正在为你的应用程序制作这个数据表。它主要是服务器端的HTML。没有什么花哨的东西。

但当你在添加列时,你遇到了一个问题。你打算如何处理小屏幕?

该表必须水平滚动以让用户看到所有的列。该表需要变得 "反应灵敏"。

在这篇文章中,我们将看看Shopify的Polaris UI工具包(目前在React中构建)中使用的一个侧滚动小部件,我们将只用Stimulus来重现这个功能,而不必在React中重写你的数据表。

我们将使用IntersectionObserver API,而不是像原来的React组件那样添加调整大小的观察器和滚动观察器,这是一个新的浏览器功能,已经广泛使用。

刺激物的快速介绍

Stimulus是一个小型库,它可以帮助你在现有的HTML中加入渐进式交互的元素。

就像CSS在元素出现在文档对象模型(DOM)中时为其添加样式一样,Stimulus也在元素出现在DOM中时为其添加交互性(事件处理程序、动作等)(并在元素从DOM中删除时将其删除)。我们将在这里使用它,因为它与Rails以及服务器端渲染的HTML搭配得非常好。

就像你可以通过向你的HTML添加CSS类来绑定样式一样,你可以通过向元素添加特殊的Stimulusdata- 属性来绑定互动性。Stimulus会观察这些属性,当有匹配的时候,它就会启动它的交互性(匹配一个名为table-scroll 的Stimulus "控制器"):

<div data-controller="table-scroll">
  <button
    class="button button-scroll-right"
    data-table-scroll-target="scrollRightButton"
    data-action="table-scroll#scrollRight"
  >
    ...
  </button>
</div>

从Shopify北极星数据表重新创建滚动导航

Shopify的UI库引入了一个聪明的侧滚动导航小部件,它只在有更多列的情况下才会显示。有按钮可以向左和向右滚动,还有小圆点来显示有多少列在视图中。

虽然原版是在React中,但我们将使用Stimulus重新创建该功能。这里的HTML来自Shopify的实现:如果你剥离所有的Polaris类,你将拥有适合你自己应用程序风格的结构。

因此,让我们开始创建你将在你的应用程序中编码的整体标记结构,并附加table-scroll Stimulus控制器。

(请注意,为了简洁起见,一些CSS样式被省略了,我已经尽可能地指出了关键的类。)

<div data-controller="table-scroll">
  <div data-table-scroll-target="navBar">
    <!-- Navigation widget -->
  </div>
  <div class="flex flex-col mx-auto">
    <div class="overflow-x-auto" data-table-scroll-target="scrollArea">
      <table class="min-w-full">
        <!-- Table contents -->
      </table>
    </div>
  </div>
</div>

接下来,让我们通过在<th> 标签上添加一个属性来为每一列设置目标。我们可以利用Stimulus的多目标绑定,将所有的列设置为目标值column ,这将允许我们在Stimulus控制器中自动绑定一个columnTargets

<!-- Table contents -->
<table class="min-w-full">
  <thead>
    <tr>
      <th data-table-scroll-target="column">Product</th>
      <th data-table-scroll-target="column">Price</th>
      <th data-table-scroll-target="column">SKU</th>
      <th data-table-scroll-target="column">Sold</th>
      <th data-table-scroll-target="column">Net Sales</th>
    </tr>
  </thead>
  <tbody>
    <!-- Table body -->
  </tbody>
</table>

接下来,让我们为导航小部件建立标记。我们将为每一列使用一个圆点图标,并使用一个左右箭头来滚动表格。

<!-- Navigation widget -->
<div data-table-scroll-target="navBar">
  <!-- Left button -->
  <button data-table-scroll-target="leftButton" data-action="table-scroll#scrollLeft">
    <svg></svg>
  </button>

  <!-- Column visibility dots -->
  <% 5.times do %>
    <span class="text-gray-200" data-table-scroll-target="columnVisibilityIndicator">
      <svg></svg>
    </span>
  <% end %>

  <!-- Scroll Right button -->
  <button data-table-scroll-target="rightButton" data-action="table-scroll#scrollRight">
    <svg></svg>
  </button>
</div>

最后,让我们传入一些类数据来定义CSS样式,以应用于导航小部件的显示或隐藏,以及按钮和点的样式。你可以选择将这些类硬编码到Stimulus控制器中,但你可能想根据你的项目需要使它们可配置(例如,你可能想在多个表格中使用这个控制器,但使用不同的颜色来表示可见列)。

<div
  data-controller="table-scroll"
  data-table-scroll-nav-shown-class="flex"
  data-table-scroll-nav-hidden-class="hidden"
  data-table-scroll-button-disabled-class="text-gray-200"
  data-table-scroll-indicator-visible-class="text-blue-600"
>
  <!-- The rest of the markup -->
</div>

使用IntersectionObserver将其变为现实

现在我们已经注释了标记,我们可以添加Stimulus控制器。

我们将需要一些方法来观察scrollArea 的位置,并检测什么是可见的。与Polaris的实现不同,我们将使用IntersectionObserver API。不需要window.resizewindow.scroll ,这比新的本地IntersectionObserver 浏览器API的性能成本要高。

IntersectionObserver API观察元素的可见性,并在可见性变化时触发一个回调。在我们的例子中,我们将观察列标题的可见性。

// controllers/table_scroll_controller.js
import { Controller } from "stimulus";

export default class extends Controller {
  static targets = [
    "navBar",
    "scrollArea",
    "column",
    "leftButton",
    "rightButton",
    "columnVisibilityIndicator",
  ];
  static classes = [
    "navShown",
    "navHidden",
    "buttonDisabled",
    "indicatorVisible",
  ];

  connect() {
    // start watching the scrollAreaTarget via IntersectionObserver
  }

  disconnect() {
    // stop watching the scrollAreaTarget, teardown event handlers
  }
}

由于我们正在用Stimulus逐步增强页面,我们应该注意检查浏览器是否支持IntersectionObserver ,如果不支持,就优雅地降级。

当控制器连接后,我们创建一个IntersectionObserver ,并提供一个回调,然后注册,我们要观察所有的columnTargets

每次updateScrollNavigation 回调被触发,(当intersectionObserver被初始化时,它也会默认触发),我们将更新每一列标题的data-is-visible 属性,以便以后被其他回调检查。

import { Controller } from "stimulus";

function supportsIntersectionObserver() {
  return (
    "IntersectionObserver" in window ||
    "IntersectionObserverEntry" in window ||
    "intersectionRatio" in window.IntersectionObserverEntry.prototype
  );
}

export default class extends Controller {
  static targets = [ ... ];
  static classes = [ ... ];

  connect() {
    this.startObservingColumnVisibility();
  }

  startObservingColumnVisibility() {
    if (!supportsIntersectionObserver()) {
      console.warn(`This browser doesn't support IntersectionObserver`);
      return;
    }

    this.intersectionObserver = new IntersectionObserver(
      this.updateScrollNavigation.bind(this),
      {
        root: this.scrollAreaTarget,
        threshold: 0.99, // otherwise, the right-most column sometimes won't be considered visible in some browsers, rounding errors, etc.
      }
    );

    this.columnTargets.forEach((headingEl) => {
      this.intersectionObserver.observe(headingEl);
    });
  }

  updateScrollNavigation(observerRecords) {
    observerRecords.forEach((record) => {
      record.target.dataset.isVisible = record.isIntersecting;
    });

    this.toggleScrollNavigationVisibility();
    this.updateColumnVisibilityIndicators();
    this.updateLeftRightButtonAffordance();
  }

  disconnect() {
    this.stopObservingColumnVisibility();
  }

  stopObservingColumnVisibility() {
    if (this.intersectionObserver) {
      this.intersectionObserver.disconnect();
    }
  }

有一些代码来设置和注册东西,但这是相当直接的,从这里开始,剩下的工作是将列的可见性与导航部件同步。

你可以看到,我们使用Stimulus中的目标绑定来切换页面中的CSS类的开启和关闭。由于我们使CSS类可配置,你可以通过编辑HTML来调整UI,而不是重建你的JavaScript捆绑。

toggleScrollNavigationVisibility() {
  const allColumnsVisible =
    this.columnTargets.length > 0 &&
    this.columnTargets[0].dataset.isVisible === "true" &&
    this.columnTargets[this.columnTargets.length - 1].dataset.isVisible ===
      "true";

  if (allColumnsVisible) {
    this.navBarTarget.classList.remove(this.navShownClass);
    this.navBarTarget.classList.add(this.navHiddenClass);
  } else {
    this.navBarTarget.classList.add(this.navShownClass);
    this.navBarTarget.classList.remove(this.navHiddenClass);
  }
}

updateColumnVisibilityIndicators() {
  this.columnTargets.forEach((headingEl, index) => {
    const indicator = this.columnVisibilityIndicatorTargets[index];

    if (indicator) {
      indicator.classList.toggle(
        this.indicatorVisibleClass,
        headingEl.dataset.isVisible === "true"
      );
    }
  });
}

updateLeftRightButtonAffordance() {
  const firstColumnHeading = this.columnTargets[0];
  const lastColumnHeading = this.columnTargets[this.columnTargets.length - 1];

  this.updateButtonAffordance(
    this.leftButtonTarget,
    firstColumnHeading.dataset.isVisible === "true"
  );
  this.updateButtonAffordance(
    this.rightButtonTarget,
    lastColumnHeading.dataset.isVisible === "true"
  );
}

updateButtonAffordance(button, isDisabled) {
  if (isDisabled) {
    button.setAttribute("disabled", "");
    button.classList.add(this.buttonDisabledClass);
  } else {
    button.removeAttribute("disabled");
    button.classList.remove(this.buttonDisabledClass);
  }
}

最后,我们需要添加点击导航按钮时触发的动作。当按钮被点击时,我们会在滚动方向上找到下一个不可见的列,然后滚动表格,使其处于该列的前缘。

scrollLeft() {
  // scroll to make visible the first non-fully-visible column to the left of the scroll area
  let columnToScrollTo = null;
  for (let i = 0; i < this.columnTargets.length; i++) {
    const column = this.columnTargets[i];
    if (columnToScrollTo !== null && column.dataset.isVisible === "true") {
      break;
    }
    if (column.dataset.isVisible === "false") {
      columnToScrollTo = column;
    }
  }

  this.scrollAreaTarget.scroll(columnToScrollTo.offsetLeft, 0);
}

scrollRight() {
  // scroll to make visible the first non-fully-visible column to the right of the scroll area
  let columnToScrollTo = null;
  for (let i = this.columnTargets.length - 1; i >= 0; i--) {
    // right to left
    const column = this.columnTargets[i];
    if (columnToScrollTo !== null && column.dataset.isVisible === "true") {
      break;
    }
    if (column.dataset.isVisible === "false") {
      columnToScrollTo = column;
    }
  }

  this.scrollAreaTarget.scroll(columnToScrollTo.offsetLeft, 0);
}

你可以通过这个gist查看完整的代码,或者通过这个Codepen玩一个互动的例子。

把它包起来

瞧!我们已经有了一个非常漂亮的、有吸引力的、可操作的、可持续发展的表格。我们已经有了一个非常漂亮的响应式滚动表。在大屏幕上,它看起来像一个普通的HTML表格。但当你缩小视图端口时,导航小部件就会出现,你可以看到点状物帮助显示表格的哪一部分是可见的。

总的来说,这个控制器的代码不到200行,应该可以在你的应用程序中处理各种尺寸的表格。

随着Hotwire的发布,Stimulus成为非SPA应用中交互性的 "最后一英里 "的重要组成部分。虽然Stimulus经常被用来运行小部分的JavaScript,但你可以建立更强大的控制器来反映功能齐全的UI库。

在你完全改变你的应用架构以使用一个花哨的客户端框架之前,看看你是否可以用你现有的HTML标记和一点Stimulus来完成。