CTA模式:如何建立一个网络组件(附代码)

419 阅读17分钟

CTA模式:如何建立一个网络组件

我得承认--我不太喜欢模态对话框(或简称为 "模态")。用 "讨厌 "这个词太强烈了,但我们可以说,在开始阅读一篇文章时,没有什么比在我还没有开始理解我正在看的东西时就被一个模态窗口 "打脸 "更令人厌恶的了。

或者,如果我可以引用Andy Budd的话

2022年的一个典型网站访问

1.弄清楚如何拒绝所有必要的cookies2
。关闭询问我是否需要帮助的支持小工具3
。停止自动播放的视频4
。关闭 "订阅我们的通讯 "的弹出窗口5
。试着记住我为什么要来这里。

- Andy Budd (@andybudd)2022年1月2

这就是说,模态在我们中间无处不在。它们是一种我们不能简单取消的用户界面范式。当有品位地明智地使用时,我敢说它们甚至可以帮助为一个文件或一个应用程序添加更多的背景。

在我的职业生涯中,我写过不少模态。我曾使用vanilla JavaScript、jQuery和最近的React构建定制的实现。如果你曾经为构建一个模态而努力,那么你会知道我说的意思。它很容易出错。不仅从视觉角度来看,还有很多棘手的用户互动需要考虑。

我是那种喜欢对困扰我的话题进行 "深入研究 "的人--尤其是当我发现这个话题再次出现的时候--希望能够避免再次重提。当我开始更多地研究Web组件时,我有一个 "啊哈!"的时刻。现在,Web组件已经被每个主要的浏览器广泛支持(RIP,IE11),这为我打开了一扇全新的机会之门。我对自己说。

"如果有可能建立一个模态,作为一个编写页面或应用程序的开发者,我不需要处理任何额外的JavaScript配置,会怎么样呢?

可以说,只需写一次,就可以到处运行,至少这是我崇高的愿望。好消息。确实有可能建立一个具有丰富交互的模态,只需要编写HTML就可以使用。

注意为了从这篇文章和代码实例中获益,你需要对HTML、CSS和JavaScript有一些基本的熟悉。

A screenshot of the CTA Modal dialog displaying a form.

A screenshot of the CTA Modal dialog displaying a scrollable piece of content.

在我们开始之前

如果你时间紧张,只想看看成品,可以在这里看看:

使用该平台

现在我们已经讨论了 "为什么 "来解决这个特殊的问题,在本文的其余部分,我将解释 "如何 "来构建它。

首先,快速了解一下网络组件的情况。它们是捆绑在一起的HTML、CSS和JavaScript的片段,封装了范围。这意味着,组件之外的样式不会影响组件内部,反之亦然。可以把它看作是UI设计的一个密闭的 "无尘室"。

乍一看,这似乎是无稽之谈。为什么我们会想要一个无法通过CSS从外部控制的UI块呢?请记住这个想法,因为我们很快就会回到这个问题上来。

最好的解释是可重复使用。以这种方式构建一个组件意味着我们不受制于任何特定的JS框架。在围绕网络标准的对话中,有一个常见的短语是 "使用平台"。现在,平台本身比以往任何时候都有极好的跨浏览器支持

深入研究

作为参考,我将参考这个代码例子-- cta-modal.ts.

注意我在这里使用TypeScript,但你绝对不要 但你绝对不需要 需要任何额外的工具来创建一个Web组件。事实上,我用vanilla JS写了我最初的概念验证。后来我加入了TypeScript,以增强其他人使用NPM包的信心。

条件包络器

有一个单一的、顶级的if ,包裹了整个文件的代码:

// ===========================
// START: if "customElements".
// ===========================

if ('customElements' in window) {
  /* NOTE: LINES REMOVED, FOR BREVITY. */
}

// =========================
// END: if "customElements".
// =========================

这样做的原因有两个方面。我们要确保有浏览器支持 window.customElements 。如果是这样,这就给我们提供了一个维护变量范围的方便方法。这意味着,当通过constlet 声明变量时,它们不会 "泄漏 "到if {…} 块之外。而使用老式的var ,则会出现问题,不经意间会创建几个全局变量。

可重复使用的变量

注意JavaScript的class Foo {…} 与HTML或CSS的class="foo" 不同。

把它简单地看成"一组函数,捆绑在一起"。

文件的这一部分包含了我打算在整个JS声明中重复使用的原始值。我将指出其中几个特别有趣的值:

// ==========
// Constants.
// ==========

/* NOTE: LINES REMOVED, FOR BREVITY. */

const ANIMATION_DURATION = 250;
const DATA_HIDE = 'data-cta-modal-hide';
const DATA_SHOW = 'data-cta-modal-show';
const PREFERS_REDUCED_MOTION = '(prefers-reduced-motion: reduce)';

const FOCUSABLE_SELECTORS = [
  '[contenteditable]',
  '[tabindex="0"]:not([disabled])',
  'a[href]',
  'audio[controls]',
  'button:not([disabled])',
  'iframe',
  "input:not([disabled]):not([type='hidden'])",
  'select:not([disabled])',
  'summary',
  'textarea:not([disabled])',
  'video[controls]',
].join(',');
  • ANIMATION_DURATION
    指定我的CSS动画将需要多长时间。我也会在后面的setTimeout 中重复使用它,以保持我的CSS和JS的同步。它被设置为250 毫秒,也就是四分之一秒。
    虽然CSS允许我们以整秒(或毫秒)来指定animation-duration ,但JS使用毫秒的增量。使用这个值可以让我同时使用它。
  • DATA_SHOW 和 这些是HTML数据属性 和 的字符串,用于控制模态的显示/隐藏,以及在CSS中调整动画时间。它们将在后面与 一起使用。DATA_HIDE
    'data-cta-modal-show' 'data-cta-modal-hide' ANIMATION_DURATION
  • PREFERS_REDUCED_MOTION
    一个媒体查询,确定用户是否将其操作系统的偏好设置为reduce ,用于prefers-reduced-motion 。我在CSS和JS中都会看这个值,以决定是否关闭动画。
  • FOCUSABLE_SELECTORS
    包含所有在模态中可以被认为是可关注的元素的CSS选择器。它在后面不止一次地被使用,通过querySelectorAll 。我在这里声明它是为了帮助可读性,而不是在函数体中增加杂乱的内容。

它等同于这个字符串。

[contenteditable], [tabindex="0"]:not([disabled]), a[href], audio[controls], button:not([disabled]), iframe, input:not([disabled]):not([type='hidden']), select:not([disabled]), summary, textarea:not([disabled]), video[controls]

讨厌,对吧!?你可以看到为什么我想把它分成多行。

作为一个精明的读者,你可能已经注意到type='hidden'tabindex="0" 使用了不同的引号。这是有目的的,我们将在后面重新审视这个理由。

组件样式

本节包含一个多行字符串,其中有一个<style> 标签。如前所述,包含在Web组件中的样式并不影响页面的其他部分。值得注意的是,我是如何通过字符串插值使用嵌入式变量${etc}

  • 我们引用我们的变量PREFERS_REDUCED_MOTION ,为那些喜欢减少动作的用户强行设置动画到none
  • 我们引用DATA_SHOWDATA_HIDE 以及ANIMATION_DURATION ,以允许对CSS动画的共享控制。注意使用ms 后缀来表示毫秒,因为这是CSS和JS的通用语言。
// ======
// Style.
// ======

const STYLE = `
  <style>
    /* NOTE: LINES REMOVED, FOR BREVITY. */

    @media ${PREFERS_REDUCED_MOTION} {
      *,
      *:after,
      *:before {
        animation: none !important;
        transition: none !important;
      }
    }

    [${DATA_SHOW}='true'] .cta-modal__overlay {
      animation-duration: ${ANIMATION_DURATION}ms;
      animation-name: SHOW-OVERLAY;
    }

    [${DATA_SHOW}='true'] .cta-modal__dialog {
      animation-duration: ${ANIMATION_DURATION}ms;
      animation-name: SHOW-DIALOG;
    }

    [${DATA_HIDE}='true'] .cta-modal__overlay {
      animation-duration: ${ANIMATION_DURATION}ms;
      animation-name: HIDE-OVERLAY;
      opacity: 0;
    }

    [${DATA_HIDE}='true'] .cta-modal__dialog {
      animation-duration: ${ANIMATION_DURATION}ms;
      animation-name: HIDE-DIALOG;
      transform: scale(0.95);
    }
  </style>
`;

组件标记

MODAL的标记是最直接的部分。这些是构成模态的基本方面:

  • 插槽
  • 可滚动区域
  • 焦点陷阱
  • 半透明的覆盖层
  • 对话窗口
  • 关闭按钮

当在一个人的页面中使用<cta-modal> 标签时,有两个内容的插入点。在这些区域内放置元素会使它们作为模态的一部分出现:

  • <div slot="button"> 映射到 。<slot name='button'>
  • <div slot="modal"> 映射到 。<slot name='modal'>

你可能想知道什么是 "焦点陷阱",以及为什么我们需要它们。这些东西的存在是为了在用户试图在模态对话框之外向前(或向后)滑动时抓住焦点。如果其中任何一个收到了焦点,它们将把浏览器的焦点放回内部。

此外,我们将这些属性赋予我们希望作为模态对话框元素的div。这告诉浏览器,<div> 在语义上是重要的。它还允许我们通过JS将焦点放在该元素上:

  • aria-modal='true',
  • role='dialog',
  • tabindex'-1'.
// =========
// Template.
// =========

const FOCUS_TRAP = `
  <span
    aria-hidden='true'
    class='cta-modal__focus-trap'
    tabindex='0'
  ></span>
`;

const MODAL = `
  <slot name='button'></slot>

  <div class='cta-modal__scroll' style='display:none'>
    ${FOCUS_TRAP}

    <div class='cta-modal__overlay'>
      <div
        aria-modal='true'
        class='cta-modal__dialog'
        role='dialog'
        tabindex='-1'
      >
        <button
          class='cta-modal__close'
          type='button'
        >×</button>

        <slot name='modal'></slot>
      </div>
    </div>

    ${FOCUS_TRAP}
  </div>
`;

// Get markup.
const markup = [STYLE, MODAL].join(EMPTY_STRING).trim().replace(SPACE_REGEX, SPACE);

// Get template.
const template = document.createElement(TEMPLATE);
template.innerHTML = markup;

你可能想知道。"为什么不使用dialog 标签?"好问题。在写这篇文章的时候,它仍然有一些跨浏览器的怪癖。关于这一点,请阅读Scott O'hara的这篇文章。另外,根据Mozilla的文档dialog 不允许有tabindex 属性,我们需要把重点放在我们的模态上。

构造器

每当一个JS类被实例化时,它的constructor 函数被调用。这只是一个花哨的术语,意味着正在创建一个CtaModal 类的实例。在我们的Web组件中,每当在页面的HTML中遇到<cta-modal> ,这种实例化就会自动发生。

constructor 中,我们调用super ,它告诉HTMLElement 类(我们是extend-ing)调用它自己的constructor 。可以把它看作是胶水代码,以确保我们利用一些默认的生命周期方法。

接下来,我们调用this._bind() ,我们会在后面再介绍一下。然后,我们将 "影子DOM "附加到我们的类实例上,并添加我们之前以多行字符串形式创建的标记。

之后,我们从前面提到的组件标记部分中获取所有的元素,以便在后面的函数调用中使用。最后,我们调用一些辅助方法,从相应的<cta-modal> 标签中读取属性。

// =======================
// Lifecycle: constructor.
// =======================

constructor() {
  // Parent constructor.
  super();

  // Bind context.
  this._bind();

  // Shadow DOM.
  this._shadow = this.attachShadow({ mode: 'closed' });

  // Add template.
  this._shadow.appendChild(
    // Clone node.
    template.content.cloneNode(true)
  );

  // Get slots.
  this._slotForButton = this.querySelector("[slot='button']");
  this._slotForModal = this.querySelector("[slot='modal']");

  // Get elements.
  this._heading = this.querySelector('h1, h2, h3, h4, h5, h6');

  // Get shadow elements.
  this._buttonClose = this._shadow.querySelector('.cta-modal__close') as HTMLElement;
  this._focusTrapList = this._shadow.querySelectorAll('.cta-modal__focus-trap');
  this._modal = this._shadow.querySelector('.cta-modal__dialog') as HTMLElement;
  this._modalOverlay = this._shadow.querySelector('.cta-modal__overlay') as HTMLElement;
  this._modalScroll = this._shadow.querySelector('.cta-modal__scroll') as HTMLElement;

  // Missing slot?
  if (!this._slotForModal) {
    window.console.error('Required [slot="modal"] not found inside cta-modal.');
  }

  // Set animation flag.
  this._setAnimationFlag();

  // Set close title.
  this._setCloseTitle();

  // Set modal label.
  this._setModalLabel();

  // Set static flag.
  this._setStaticFlag();

  /*
  =====
  NOTE:
  =====

    We set this flag last because the UI visuals within
    are contingent on some of the other flags being set.
  */

  // Set active flag.
  this._setActiveFlag();
}

绑定this Context

这是JS的一个小技巧,使我们不必在其他地方无谓地输入繁琐的代码。在处理DOM事件时,this 的上下文会发生变化,这取决于页面中正在与什么元素进行交互。

确保this 总是意味着我们的类的实例的一种方法是专门调用bind 。从本质上讲,这个函数使它,使它被自动处理。这意味着我们不必到处输入这样的东西:

/* NOTE: Just an example, we don't need this. */
this.someFunctionName1 = this.someFunctionName1.bind(this);
this.someFunctionName2 = this.someFunctionName2.bind(this);

而不是键入上面的那个片段,每次我们添加一个新的函数时,constructor 中的一个方便的this._bind() 调用会处理我们可能有的任何/所有函数。这个循环抓取每一个属于function 的类属性,并自动将其绑定:

// ============================
// Helper: bind `this` context.
// ============================

_bind() {
  // Get property names.
  const propertyNames = Object.getOwnPropertyNames(
    // Get prototype.
    Object.getPrototypeOf(this)
  ) as (keyof CtaModal)[];

  // Loop through.
  propertyNames.forEach((name) => {
    // Bind functions.
    if (typeof this[name] === FUNCTION) {
      /*
      =====
      NOTE:
      =====

        Why use "@ts-expect-error" here?

        Calling `*.bind(this)` is a standard practice
        when using JavaScript classes. It is necessary
        for functions that might change context because
        they are interacting directly with DOM elements.

        Basically, I am telling TypeScript:

        "Let me live my life!"

        😎
      */

      // @ts-expect-error bind
      this[name] = this[name].bind(this);
    }
  });
}

生命周期方法

extend 根据这一行的性质,在我们从HTMLElement ,我们得到了一些内置的函数调用 "free"。只要我们用这些名字命名我们的函数,它们就会在我们的<cta-modal> 组件的生命周期中的适当时间被调用。

// ==========
// Component.
// ==========

class CtaModal extends HTMLElement {
  /* NOTE: LINES REMOVED, FOR BREVITY. */
}
  • observedAttributes
    这告诉浏览器我们正在观察哪些属性的变化。
  • attributeChangedCallback
    如果这些属性中的任何一个发生变化,这个回调就会被调用。根据哪个属性的变化,我们调用一个函数来读取该属性。
  • connectedCallback
    当一个<cta-modal> 标签被注册到页面上时,这将被调用。我们利用这个机会来添加我们所有的事件处理程序。
    如果你熟悉React,这类似于componentDidMount 生命周期事件。
  • disconnectedCallback
    当一个<cta-modal> 标签从页面上被删除时,这将被调用。同样地,当/如果发生这种情况时,我们会删除所有过时的事件处理程序。
    这类似于React中的componentWillUnmount 生命周期事件。

注意值得指出的是,这些是我们类中唯一没有下划线前缀的函数 (_)。虽然不是严格意义上的必要,但这样做的原因有两个。第一,它使我们很明显地看到哪些函数是为我们的新<cta-modal> ,哪些是HTMLElement 类的本地生命周期事件。第二,当我们以后简化代码时,前缀表示它们可以被混合。而本地生命周期方法则需要逐字保留它们的名字。

// ============================
// Lifecycle: watch attributes.
// ============================

static get observedAttributes() {
  return [ACTIVE, ANIMATED, CLOSE, STATIC];
}

// ==============================
// Lifecycle: attributes changed.
// ==============================

attributeChangedCallback(name: string, oldValue: string, newValue: string) {
  // Different old/new values?
  if (oldValue !== newValue) {
    // Changed [active="…"] value?
    if (name === ACTIVE) {
      this._setActiveFlag();
    }

    // Changed [animated="…"] value?
    if (name === ANIMATED) {
      this._setAnimationFlag();
    }

    // Changed [close="…"] value?
    if (name === CLOSE) {
      this._setCloseTitle();
    }

    // Changed [static="…"] value?
    if (name === STATIC) {
      this._setStaticFlag();
    }
  }
}

// ===========================
// Lifecycle: component mount.
// ===========================

connectedCallback() {
  this._addEvents();
}

// =============================
// Lifecycle: component unmount.
// =============================

disconnectedCallback() {
  this._removeEvents();
}

添加和删除事件

这些函数为各种元素和页面级别的事件注册(和删除)回调:

  • 按钮被点击
  • 元素聚焦
  • 键盘按压
  • 覆盖层被点击
// ===================
// Helper: add events.
// ===================

_addEvents() {
  // Prevent doubles.
  this._removeEvents();

  document.addEventListener(FOCUSIN, this._handleFocusIn);
  document.addEventListener(KEYDOWN, this._handleKeyDown);

  this._buttonClose.addEventListener(CLICK, this._handleClickToggle);
  this._modalOverlay.addEventListener(CLICK, this._handleClickOverlay);

  if (this._slotForButton) {
    this._slotForButton.addEventListener(CLICK, this._handleClickToggle);
    this._slotForButton.addEventListener(KEYDOWN, this._handleClickToggle);
  }

  if (this._slotForModal) {
    this._slotForModal.addEventListener(CLICK, this._handleClickToggle);
    this._slotForModal.addEventListener(KEYDOWN, this._handleClickToggle);
  }
}

// ======================
// Helper: remove events.
// ======================

_removeEvents() {
  document.removeEventListener(FOCUSIN, this._handleFocusIn);
  document.removeEventListener(KEYDOWN, this._handleKeyDown);

  this._buttonClose.removeEventListener(CLICK, this._handleClickToggle);
  this._modalOverlay.removeEventListener(CLICK, this._handleClickOverlay);

  if (this._slotForButton) {
    this._slotForButton.removeEventListener(CLICK, this._handleClickToggle);
    this._slotForButton.removeEventListener(KEYDOWN, this._handleClickToggle);
  }

  if (this._slotForModal) {
    this._slotForModal.removeEventListener(CLICK, this._handleClickToggle);
    this._slotForModal.removeEventListener(KEYDOWN, this._handleClickToggle);
  }
}

检测属性变化

这些函数处理从<cta-modal> 标签中读取属性,并因此设置各种标志:

  • 在我们的类实例上设置一个_isAnimated 布尔值。
  • 在我们的关闭按钮上设置titlearia-label 属性。
  • 根据标题文本,为我们的模态对话框设置一个aria-label
  • 在我们的类实例上设置一个_isActive 布尔值。
  • 在我们的类实例上设置一个_isStatic 布尔值。

你可能想知道为什么我们要使用aria-label ,将模态与标题文本联系起来(如果它存在的话)。在写这篇文章的时候,浏览器目前还不能将阴影DOM中的aria-labelledby="…" 属性与标准(又称 "光")DOM中的id="…" 联系起来。

// ===========================
// Helper: set animation flag.
// ===========================

_setAnimationFlag() {
  this._isAnimated = this.getAttribute(ANIMATED) !== FALSE;
}

// =======================
// Helper: add close text.
// =======================

_setCloseTitle() {
  // Get title.
  const title = this.getAttribute(CLOSE) || CLOSE_TITLE;

  // Set title.
  this._buttonClose.title = title;
  this._buttonClose.setAttribute(ARIA_LABEL, title);
}

// ========================
// Helper: add modal label.
// ========================

_setModalLabel() {
  // Set later.
  let label = MODAL_LABEL_FALLBACK;

  // Heading exists?
  if (this._heading) {
    // Get text.
    label = this._heading.textContent || label;
    label = label.trim().replace(SPACE_REGEX, SPACE);
  }

  // Set label.
  this._modal.setAttribute(ARIA_LABEL, label);
}

// ========================
// Helper: set active flag.
// ========================

_setActiveFlag() {
  // Get flag.
  const isActive = this.getAttribute(ACTIVE) === TRUE;

  // Set flag.
  this._isActive = isActive;

  // Set display.
  this._toggleModalDisplay(() => {
    // Focus modal?
    if (this._isActive) {
      this._focusModal();
    }
  });
}

// ========================
// Helper: set static flag.
// ========================

_setStaticFlag() {
  this._isStatic = this.getAttribute(STATIC) === TRUE;
}

聚焦特定元素

_focusElement 函数允许我们聚焦一个可能在模态激活之前就已经激活的元素。而_focusModal 函数会将焦点放在模态对话框本身,并确保模态背景被滚动到顶部。

// ======================
// Helper: focus element.
// ======================

_focusElement(element: HTMLElement) {
  window.requestAnimationFrame(() => {
    if (typeof element.focus === FUNCTION) {
      element.focus();
    }
  });
}

// ====================
// Helper: focus modal.
// ====================

_focusModal() {
  window.requestAnimationFrame(() => {
    this._modal.focus();
    this._modalScroll.scrollTo(0, 0);
  });
}

检测 "外部 "模态

这个函数可以方便地知道一个元素是否在父<cta-modal> 标签之外。它返回一个布尔值,我们可以用它来采取适当的行动。也就是说,当模态处于活动状态时,标签会在模态内捕获导航。

// =============================
// Helper: detect outside modal.
// =============================

_isOutsideModal(element?: HTMLElement) {
  // Early exit.
  if (!this._isActive || !element) {
    return false;
  }

  // Has element?
  const hasElement = this.contains(element) || this._modal.contains(element);

  // Get boolean.
  const bool = !hasElement;

  // Expose boolean.
  return bool;
}

检测运动偏好

在这里,我们重新使用之前的变量(也用于我们的CSS)来检测用户是否同意运动。也就是说,他们没有通过他们的操作系统偏好明确地将prefers-reduced-motionreduce

返回的布尔值是该检查的组合,加上animated="false" 标志没有被设置在<cta-modal>

// ===========================
// Helper: detect motion pref.
// ===========================

_isMotionOkay() {
  // Get pref.
  const { matches } = window.matchMedia(PREFERS_REDUCED_MOTION);

  // Expose boolean.
  return this._isAnimated && !matches;
}

切换模式的显示/隐藏

在这个函数中,有相当多的事情要做,但本质上,它是非常简单的:

  • 如果模态没有被激活,就显示它:如果允许有动画,就把它做成动画。
  • 如果模态是活动的,就隐藏它:如果允许有动画,就用动画使它消失。

我们还缓存了当前的活动元素,这样当模态关闭时我们就可以恢复焦点。

先前在我们的CSS中使用的变量也在这里使用:

  • ANIMATION_DURATION,
  • DATA_SHOW,
  • DATA_HIDE.
// =====================
// Helper: toggle modal.
// =====================

_toggleModalDisplay(callback: () => void) {
  // @ts-expect-error boolean
  this.setAttribute(ACTIVE, this._isActive);

  // Get booleans.
  const isModalVisible = this._modalScroll.style.display === BLOCK;
  const isMotionOkay = this._isMotionOkay();

  // Get delay.
  const delay = isMotionOkay ? ANIMATION_DURATION : 0;

  // Get scrollbar width.
  const scrollbarWidth = window.innerWidth - document.documentElement.clientWidth;

  // Get active element.
  const activeElement = document.activeElement as HTMLElement;

  // Cache active element?
  if (this._isActive && activeElement) {
    this._activeElement = activeElement;
  }

  // =============
  // Modal active?
  // =============

  if (this._isActive) {
    // Show modal.
    this._modalScroll.style.display = BLOCK;

    // Hide scrollbar.
    document.documentElement.style.overflow = HIDDEN;

    // Add placeholder?
    if (scrollbarWidth) {
      document.documentElement.style.paddingRight = `${scrollbarWidth}px`;
    }

    // Set flag.
    if (isMotionOkay) {
      this._isHideShow = true;
      this._modalScroll.setAttribute(DATA_SHOW, TRUE);
    }

    // Fire callback.
    callback();

    // Await CSS animation.
    this._timerForShow = window.setTimeout(() => {
      // Clear.
      clearTimeout(this._timerForShow);

      // Remove flag.
      this._isHideShow = false;
      this._modalScroll.removeAttribute(DATA_SHOW);

      // Delay.
    }, delay);

    /*
    =====
    NOTE:
    =====

      We want to ensure that the modal is currently
      visible because we do not want to put scroll
      back on the `<html>` element unnecessarily.

      The reason is that another `<cta-modal>` in
      the page might have been pre-rendered with an
      [active="true"] attribute. If so, we want to
      leave the page's overflow value alone.
    */
  } else if (isModalVisible) {
    // Set flag.
    if (isMotionOkay) {
      this._isHideShow = true;
      this._modalScroll.setAttribute(DATA_HIDE, TRUE);
    }

    // Fire callback?
    callback();

    // Await CSS animation.
    this._timerForHide = window.setTimeout(() => {
      // Clear.
      clearTimeout(this._timerForHide);

      // Remove flag.
      this._isHideShow = false;
      this._modalScroll.removeAttribute(DATA_HIDE);

      // Hide modal.
      this._modalScroll.style.display = NONE;

      // Show scrollbar.
      document.documentElement.style.overflow = EMPTY_STRING;

      // Remove placeholder.
      document.documentElement.style.paddingRight = EMPTY_STRING;

      // Delay.
    }, delay);
  }
}

处理事件,点击叠加

当点击半透明的覆盖层时,假设<cta-modal> 标签上没有设置static="true" ,我们就关闭模态:

// =====================
// Event: overlay click.
// =====================

_handleClickOverlay(event: MouseEvent) {
  // Early exit.
  if (this._isHideShow || this._isStatic) {
    return;
  }

  // Get layer.
  const target = event.target as HTMLElement;

  // Outside modal?
  if (target.classList.contains('cta-modal__overlay')) {
    this._handleClickToggle();
  }
}

处理事件,击切换

这个函数在<div slot="button"><div slot="modal"> 元素上使用事件委托。每当一个具有cta-modal-toggle 类别的子元素被触发,它将导致模态的活动状态改变。

这包括监听各种被认为是激活按钮的事件:

  • 鼠标点击。
  • 按下enter 键。
  • 按下spacebar 键。
// ====================
// Event: toggle modal.
// ====================

_handleClickToggle(event?: MouseEvent | KeyboardEvent) {
  // Set later.
  let key = EMPTY_STRING;
  let target = null;

  // Event exists?
  if (event) {
    if (event.target) {
      target = event.target as HTMLElement;
    }

    // Get key.
    if ((event as KeyboardEvent).key) {
      key = (event as KeyboardEvent).key;
      key = key.toLowerCase();
    }
  }

  // Set later.
  let button;

  // Target exists?
  if (target) {
    // Direct click.
    if (target.classList.contains('cta-modal__close')) {
      button = target as HTMLButtonElement;

      // Delegated click.
    } else if (typeof target.closest === FUNCTION) {
      button = target.closest('.cta-modal-toggle') as HTMLButtonElement;
    }
  }

  // Get booleans.
  const isValidEvent = event && typeof event.preventDefault === FUNCTION;
  const isValidClick = button && isValidEvent && !key;
  const isValidKey = button && isValidEvent && [ENTER, SPACE].includes(key);

  const isButtonDisabled = button && button.disabled;
  const isButtonMissing = isValidEvent && !button;
  const isWrongKeyEvent = key && !isValidKey;

  // Early exit.
  if (isButtonDisabled || isButtonMissing || isWrongKeyEvent) {
    return;
  }

  // Prevent default?
  if (isValidKey || isValidClick) {
    event.preventDefault();
  }

  // Set flag.
  this._isActive = !this._isActive;

  // Set display.
  this._toggleModalDisplay(() => {
    // Focus modal?
    if (this._isActive) {
      this._focusModal();

      // Return focus?
    } else if (this._activeElement) {
      this._focusElement(this._activeElement);
    }
  });
}

处理事件,注元素

每当一个元素在页面上收到focus ,这个函数就会被触发。根据模态的状态,以及哪个元素被关注,我们可以在模态对话框中捕获标签导航。这就是我们早期的FOCUSABLE_SELECTORS 的作用所在:

// =========================
// Event: focus in document.
// =========================

_handleFocusIn() {
  // Early exit.
  if (!this._isActive) {
    return;
  }

  // prettier-ignore
  const activeElement = (
    // Get active element.
    this._shadow.activeElement ||
    document.activeElement
  ) as HTMLElement;

  // Get booleans.
  const isFocusTrap1 = activeElement === this._focusTrapList[0];
  const isFocusTrap2 = activeElement === this._focusTrapList[1];

  // Set later.
  let focusListReal: HTMLElement[] = [];

  // Slot exists?
  if (this._slotForModal) {
    // Get "real" elements.
    focusListReal = Array.from(
      this._slotForModal.querySelectorAll(FOCUSABLE_SELECTORS)
    ) as HTMLElement[];
  }

  // Get "shadow" elements.
  const focusListShadow = Array.from(
    this._modal.querySelectorAll(FOCUSABLE_SELECTORS)
  ) as HTMLElement[];

  // Get "total" elements.
  const focusListTotal = focusListShadow.concat(focusListReal);

  // Get first & last items.
  const focusItemFirst = focusListTotal[0];
  const focusItemLast = focusListTotal[focusListTotal.length - 1];

  // Focus trap: above?
  if (isFocusTrap1 && focusItemLast) {
    this._focusElement(focusItemLast);

    // Focus trap: below?
  } else if (isFocusTrap2 && focusItemFirst) {
    this._focusElement(focusItemFirst);

    // Outside modal?
  } else if (this._isOutsideModal(activeElement)) {
    this._focusModal();
  }
}

处理事件,盘

如果一个模态在按下escape 键时处于活动状态,它将被关闭。如果tab 键被按下,我们会评估是否需要调整哪个元素被关注:

// =================
// Event: key press.
// =================

_handleKeyDown({ key }: KeyboardEvent) {
  // Early exit.
  if (!this._isActive) {
    return;
  }

  // Get key.
  key = key.toLowerCase();

  // Escape key?
  if (key === ESCAPE && !this._isHideShow && !this._isStatic) {
    this._handleClickToggle();
  }

  // Tab key?
  if (key === TAB) {
    this._handleFocusIn();
  }
}

DOM加载的回调

这个事件监听器告诉窗口等待DOM(HTML页面)被加载,然后分析它是否有<cta-modal> 的实例,并将我们的JS交互性附加到它。本质上,我们已经创建了一个新的HTML标签,现在浏览器知道如何使用它:

// ===============
// Define element.
// ===============

window.addEventListener('DOMContentLoaded', () => {
  window.customElements.define('cta-modal', CtaModal);
});

构建时间优化

我不会对这个方面进行详细介绍,但我认为这值得一提。

从TypeScript转译到JavaScript后,我针对JS的输出运行Terser。所有上述以下划线开头的函数(_ )都被标记为可以安全地进行修改。也就是说,它们从被命名为_bind_addEvents 改为单字母。

这一步使文件的大小大大减少。然后,我通过我创建的minifyWebComponent.js进程来运行最小化的输出,这将进一步压缩嵌入式<style> 和标记。

例如,类名和其他属性(和选择器)都被压缩了。这发生在CSS和HTML中:

  • class='cta-modal__overlay' 成为 。引号也被删除了,因为浏览器在技术上不需要它们来理解其意图。class=o
  • 一个没有被触动的CSS选择器是[tabindex="0"] ,因为去掉0 周围的引号似乎会使它在被querySelectorAll 解析时失效。然而,在HTML中从tabindex='0'tabindex=0 是安全的。

当这一切都完成后,文件大小的减少看起来像这样(以字节为单位)。

  • 未减化。16,849,
  • 更多的最小化。10,230,
  • 和我的脚本。7,689.

从这个角度来看,Smashing杂志上的favicon.ico 文件是4286字节。所以,对于很多只需要写HTML就可以使用的功能,我们其实根本没有增加多少开销。

总结

如果你已经读到这里,感谢你坚持不懈地支持我。我希望我至少激起了你对Web组件的兴趣

我知道我们涵盖了相当多的内容,但好消息是。这就是它的全部内容。除非你愿意,否则没有框架需要学习。现实上,你可以使用vanilla JS开始编写你自己的Web组件,而不需要构建过程。

现在真的是最好的时机,#UseThePlatform 。我期待着看到你的想象。