定位与堆叠上下文

本章概要

  • 元素定位的类型:固定(fixed)定位、相对(relative)定位、绝对(absolute)定位、以及粘性(sticky)定位
  • 构建模态对话框和下拉菜单
  • 深入理解 z-index 和堆叠上下文
  • 用 CSS 绘制一个简单的三角形

我们现在已经学习了好几种页面布局方式,包括常规文档流布局、Flexbox 布局以及网格布局。本章将介绍另一项重要的技术:position 属性(property)。它可以用来构建下拉菜单、模态对话框以及现代 Web 应用的其他基本效果。

定位处理起来可能会很复杂,很多开发者对此也仅有粗浅的认知。如果没有彻底理解定位及其可能带来的后果,就很容易给自己挖坑。比如遇到某些元素被错误地放置在了其他元素的前面,而纠正这个问题却没有那么简单。

相信在考察完各式各样的定位类型后,您一定可以准确理解每一种定位的行为特征。此外,本章还将引入一个新名词,称为 堆叠上下文(stacking context),姑且算作定位问题潜在的副作用吧。深入理解堆叠上下文能帮您少走很多弯路,哪怕是不慎钻进某个页面布局的死胡同,对堆叠上下文的准确理解也可以助您快速突出重围。

position 属性的初始值为 static。前面的章节中默认用的都是这个静态定位。如果改为其他值,我们就说该元素 已定位(positioned);而处于静态定位下的元素,我们就说它 未被定位(not positioned)。

前面章节介绍的布局方法是通过各种操作来控制文档流的行为。定位则不同:它将元素彻底从文档流中移走。这样,元素就能位于屏幕的任意位置,或者将元素放在另一个元素的前方或后方,彼此间相互重叠。

6.1 固定定位

Fixed positioning

固定定位虽不如其他定位类型来得普遍,但它可能也是这当中最好理解的定位类型了,我们就从这里切入。给一个元素设置 position: fixed,就能将其放到视口(viewport)的任意位置。这是通过搭配另外四个属性来实现的:toprightbottom 以及 left。这些属性的值决定了固定定位的元素与浏览器视口边缘的相对距离。比如,top: 3em 表示元素的上边缘距离视口顶部 3em

设置这四个值还间接定义了元素的宽高。例如,设置 left: 2em; right: 2em 表示元素左边缘距视口左边 2em,且其右边缘距离视口右边为 2em。因此元素的宽度等于视口总宽度减 4em。同理,属性 topbottom 与视口高度也满足这样的关系。

此外,还可以使用简写属性 inset 来设置元素位置。该属性可以非常方便地一次性设置元素四周的距离,例如 inset: 0 等价于 top: 0; right: 0; bottom: 0; left: 0。能用 inset 属性进行简写的逻辑属性(译注:即 logical properties,见第 3章 3.1.2 小节内容)如下:

以上罗列的这些简写属性,在需要通过 toprightbottomleft 属性提供冗长样式声明的场景下通常会很管用。inset 属性本身最多可以指定四个值,用于单独设置对应方向的距离大小。

6.1.1 创建一个固定定位的模态对话框

Creating a modal dialog with fixed positioning

本节将用这些属性来实现如图 6.1 所示的模态对话框。该对话框会在网页内容前方弹出并遮挡住网页内容,直到关闭该对话框。

模态对话框(modal dialog box)是一个出现在主页面前方的窗口。显示该窗口期间,其后面的用户界面将被全面禁用;用户必须通过某种方式与模态框进行交互方能返回主页面。

图 6.1 模态对话框示意图
图 6.1 模态对话框示意图

通常情况下,模态对话框用于要求用户阅读一些内容,或者在进行下一步操作之前输入一些内容。如图 6.1 所示,该模态框展示了一个表单供用户订阅推送消息。实现该效果时,初始状态下可以先声明 display: none 来隐藏该弹窗;若要显示它,则可以通过 JavaScript 将 display 属性值改为 block 来实现。

下面创建一个新页面,将代码清单 7.1 中的内容加到页面的 <body> 元素中。该代码将所有内容放在两个容器元素中,并通过 <script> 标签引入一段 JavaScript 脚本,以此来实现一些基础功能。注意,只有等到正确的 CSS 样式加进来,模态框才会正常运转。

代码清单 6.1 创建模态对话框的 HTML 标记内容

<header class="top-banner">
  <div class="top-banner-inner">
    <p>Find out what's going on at Wombat Coffee each
      month. Sign up for our newsletter:
      <button id="open" type="button">Sign up</button><!-- 触发弹窗的按钮 -->
    </p>
  </div>
</header>
<div class="modal" id="modal" role="dialog" aria-modal="true"><!-- 模态框容器 -->
  <div class="modal-backdrop"></div><!-- 模态框后面遮挡网页内容的“蒙层” -->
  <div class="modal-body"><!-- 模态框内容 -->
    <button class="modal-close" id="close" type="button">close</button>
    <h2>Wombat Newsletter</h2>
    <p>Sign up for our monthly newsletter. No spam.
       We promise!</p>
    <form>
      <p>
        <label for="email">Email address:</label>
        <input type="text" name="email"/>
      </p>
      <p><button type="submit">Submit</button></p>
    </form>
  </div>
</div>
<main class="container">
  <h1>Wombat Coffee Roasters</h1>
</main>
 
<script type="text/javascript">
  const button = document.getElementById('open');
  const close = document.getElementById('close');
  const modal = document.getElementById('modal');

  // 用户点击注册按钮时,打开模态对话框
  button.addEventListener('click', function (event) {
    modal.classList.add('is-open');
  });
  // 用户点击关闭按钮时,关闭模态对话框
  close.addEventListener('click', function (event) {
    modal.classList.remove('is-open');
  });
</script>

代码中的第一个元素为页面顶部的横幅区,里面包含触发模态框的按钮。第二个元素则为模态框,里面包含了一个空的 modal-backdrop 元素,用于遮住页面剩余部分,让用户的注意力集中到对话框的内容上。弹框内容则放在 modal-body 元素内。

这里的 JavaScript 代码则用于在模态框中添加或移除一个 is-open 类,并通过这个样式类来切换模态框的可见性。

注意

目前 HTML 自带的 <dialog> 元素已经具备了大量模态对话框的相关功能。但为了更全面地说明固定定位的行为特征,本例并没有使用 <dialog> 元素。想了解该元素的更多信息,可以参阅我的这篇文章:https://keithjgrant.com/posts/2018/01/meet-the-new-dialog-element/

初始页面的样式设计如以下代码所示。开始定位前,还有几个地方要提前交代一下。页面中的 <body> 元素,高度设置为 200vh,旨在令页面强制滚动;这样设计也有助于观察本章演示的定位元素与页面滚动间的交互情况。此外,还可以在 <main> 元素中多加几段内容,直到页面出现滚动条。

根据代表清单 6.2 更新样式表。该样式包括了顶部横幅及按钮的基本样式:

代码清单 6.2 页面初始样式

body {
  font-family: Helvetica, Arial, sans-serif;
  min-height: 200vh;  /* 设置固定高度,强行让页面出现滚动条(仅用于演示) */
  margin: 0;
}
 
button,
input {
  font: inherit;  /* 覆盖用户代理默认的表单字体样式 */
}
 
button {
  padding: 0.5em 0.7em;
  border: 1px solid #8d8d8d;
  background-color: #eee;
  border-radius: 5px;
  cursor: pointer;
}
 
.top-banner {
  padding: 0.5em;
  background-color: #ffd698;
  box-shadow: 0 1px 5px rgb(0 0 0 / 0.1);
}
 
.top-banner-inner {
  width: 80%;
  max-inline-size: 1000px;
  margin-inline: auto;
}
 
.container {
  width: 80%;
  max-inline-size: 1000px;
  margin: 1em auto;
}
 
.modal {
  display: none;  /* 默认隐藏模态对话框 */
}
 
.modal.is-open {
  display: block;  /* 当存在 is-open 类时显示模态框 */
}

上述样式还对按钮和文本输入框设置了 font: inherit。该声明用于覆盖表单元素中一些默认的用户代理样式。这也是页面样式设计一个值得推荐的常见做法。

最后,模态框会在 display: none 的作用下初始隐藏;等到样式类 is-open 添加完毕,模态框又会在 display: block 的作用下重新显示到页面。

对于模态框本身的样式设置,需要用到固定定位两次:第一次是针对 modal-backdrop 元素,其 inset 属性值为 0。这样,蒙层就会填满整个视口。蒙层的背景色为 rgba(0 0 0 / 0.5),其中红、绿、蓝三原色的值均为 0,算出来是黑色;第四个值即“alpha”通道,代表透明度:为 0 时表示完全透明;为 1 时则表示完全不透明。这里的 0.5 表示半透明。这样一来该元素下面所有的网页内容都会变暗。

而第二次固定定位则用于样式类 modal-body 所在的元素。它的四条边都在视口内:上下两边分别距离对应的视口边缘 3em,左右两边则与视口左右边缘保持 20% 的距离。由于背景色为白色,所以模态框呈现为一个在屏幕居中的白色盒子。此时虽然可以随意滚动页面,但背景蒙层与模态对话框主体均不会移动。

根据代码清单 6.3 更新本地样式表。

代码清单 6.3 添加模态框样式

.modal-backdrop {
  /* 使用半透明背景,在模态框打开时遮住页面其他部分 */
  position: fixed;
  inset: 0;
  background-color: rgba(0, 0, 0, 0.5);
}
 
.modal-body {
  /* 定位模态框的主要部分 */
  position: fixed;
  inset-block: 3em;
  inset-inline: 20%;
  
  padding: 2em 3em;
  background-color: white;
  overflow: auto;
}

打开页面后,会看到屏幕顶部有一个带按钮的淡黄色横幅。单击按钮就会弹出设置好定位的模态对话框。由于是固定定位,此时即便滚动页面,模态框仍旧保持在原位不动。

要关闭模态对话框,点击它顶部的关闭按钮(Close)即可。此时按钮的位置还有点问题,稍后再作调整。

6.1.2 在模态对话框打开时防止屏幕滚动

Preventing the screen from scrolling while the modal dialog is open

在模态对话框打开时滚动页面,虽然可以进一步观察固定定位的工作原理,但此时的用户体验并不十分理想。当模态框位于页面内容前方时,通常用户只希望与模态对话框本身进行交互,其他内容则与之无关。

要修复这问题,可以在模态对话框打开时,在 <body> 元素上设置 overscroll: hidden 即可,例如:

body.no-scroll {
  overflow: hidden;
}

接着更新上述样式,并根据代码清单 6.4 所示的 JavaScript 脚本修改示例页面。该代码通过在 body 元素上添加或移除 no-scroll 样式类来切换上面提到的样式。

代码清单 6.4 禁用页面滚动的新版 JavaScript 代码

<script type="text/javascript">
  const button = document.getElementById("open");
  const close = document.getElementById("close");
  const modal = document.getElementById("modal");
 
  button.addEventListener("click", function (event) {
    modal.classList.add("is-open");
    document.body.classList.add("no-scroll");
  });

  close.addEventListener("click", function (event) {
    modal.classList.remove("is-open");
    document.body.classList.remove("no-scroll");
  });
</script>

这样一来,打开模态对话框时,页面后方内容将不再触发滚动;模态框关闭后,页面滚动才重新得以恢复。

对于存在页面交互的元素,像这样通过添加或移除样式类来实现交互的做法非常强大。它既能保证 JavaScript 代码相对简单,同时又能将关键的视觉处理逻辑留在样式表中。

提示

在某些浏览器中,也可以不使用 JavaScript,直接通过伪类 :has() 实现同样的功能。这样,本例使用的选择器 body.no-scroll 就要改为 :has(.modal.is-open);后者表示仅在模态对话框存在 is-open 类时才会对 body 元素设置页面禁止滚动。由于 Firefox 直到 2023 年底才添加了对伪类 :has() 的支持,选用这种写法可能还不太通用。有关伪类 :has() 的更多信息,请参阅 附录 A

6.1.3 控制定位元素的大小

Controlling the size of positioned elements

在定位一个元素时,不用每次都必须写全四个方向的值,也可以只设置需要的方向值,然后用 width 和/或 height 属性来决定其大小。有时还可以通过元素本身来决定大小,例如以下声明:

position: fixed;
top: 1em;
right: 1em;
width: 20%;

这段代码会将元素放置在距离视口顶部及右边 1em 的位置,宽度则为视口宽度的 20%。它省略了 bottomheight 属性,元素高度则由自身内容决定。作为示例,该写法可用于将一个导航菜单固定到屏幕上。即使用户向下滚动页面,导航菜单的位置也不会改变。

因为固定定位元素从文档流中移除了,因此它将不再影响页面其他元素的位置。剩下的这些元素将按照常规文档流进行排布,就好像固定元素不存在了一样。也就是说,它们通常会流动到固定元素的下面不被看到。这对于模态框来说问题不大,毕竟我们也希望模态框在用户关闭之前始终处在最前方的中间位置。

而对于那些一直都在的元素,例如侧边导航栏,就需要注意不要让其他内容出现在它下面。要解决这个问题,给其他内容添加一个外边距的做法往往是最简单的。比如,将所有内容放入一个右外边距为 20% 的容器中,该外边距就会流动到设置了固定定位的元素下方,从而不让内容与导航栏重叠。

6.2 绝对定位

Absolute positioning

上一节介绍的固定定位,是相对于视口(viewport)来定位元素的。这个作参照物的视口也被称为该元素的 包含块(containing block)。例如,声明 left: 2em 会让一个定位元素(positioned element)的左边缘放到距离其包含块 2em 的位置。

绝对定位(Absolute positioning)的行为也是如此,只是它的包含块不一样。绝对定位不是相对于视口,而是相对于 最近的祖先定位元素(closest-positioned ancestor element)。与固定元素(fixed element)一样,其 inset 属性(包括 toprightbottomleft)决定了元素边缘在包含块里的具体位置。

6.2.1 关闭按钮的绝对定位

Absolutely positioning the Close button

为了演示绝对定位,本节将重新设置关闭(close)按钮的位置,并将其放置在模态对话框的右上角,如图 6.2 所示。

图 6.2 关闭按钮被定位到模态对话框的右上角

图 6.2 关闭按钮被定位到模态对话框的右上角

实现上述效果,需要将关闭按钮设置为绝对定位。由于其父元素 modal-body 是固定定位的,因此会成为关闭按钮的包含块。根据以下代码更新关闭按钮的样式。

代码清单 6.5 绝对定位的关闭按钮样式

.modal-close {
  /* Positions relative to the relatively-positioned parent
  该相对位置的参照物,为已设置“固定”定位的父元素(原文注释貌似有误?) */
  position: absolute;
  top: 0.3em;
  right: 0.3em;

  padding: 0.3em;
}

这段代码将按钮放置在了距离 modal-body 顶部 0.3em、右侧 0.3em 的位置。正如本例所示,包含块通常为是目标元素的父元素。如果父元素未被定位(not positioned),浏览器则会沿着 DOM 树往上查找其祖父、曾祖父,直到找到一个定位元素(positioned element)为止,然后将它作为包含块。

注意

如果祖先元素都没有定位,那么这个绝对定位的元素会基于 初始包含块(initial containing block 来定位。初始包含块的大小与视口相同,并固定在页面顶部。

6.2.2 伪元素的定位问题

Positioning a pseudo-element

关闭按钮的定位就这样搞定了,只是看着有些简陋。对于这种关闭按钮,用户往往更希望看到一个类似 x 的图形化标记,如图 6.3 所示。

图 6.3 将关闭按钮的“close”字样改为“x”

图 6.3 将关闭按钮的“close”字样改为“x”

您可能首先想到的是把关闭按钮中的 close 直接改为字母 x,但是这样会导致一些与页面可访问性(accessibility problem)相关的问题:屏幕辅助阅读工具会根据按钮中的文字进行阅读。因此需要给按钮一些有意义的提示信息。在使用 CSS 之前,HTML 本身的语义也必须有意义才行。

解决了语义化相关的问题,CSS 就可以放心大胆地隐藏 close 字样并显示 x 了。实现最终效果总共需要两步。先将按钮里的文字推至按钮外围并隐藏溢出内容;然后将按钮的伪元素 ::aftercontent 属性设置为 x,并通过绝对定位让伪元素在按钮内居中显示。根据代码清单 6.4 更新按钮样式。

说明

相较字母 x,我更推荐选用乘法符号对应的 Unicode 字符。它的对称性更好,也更美观。此外,HTML 的字符实体 &times;(即 ×)虽然也可以显示该字符,但在 CSS 伪元素的 content 的属性中,必须使用转义后的 Unicode 编码:\00D7

代码清单 6.6 替换为 × 符号的关闭按钮样式代码

.modal-close {
  position: absolute;
  top: 0.3em;
  right: 0.3em;
  padding: 0.3em;
  border: 0;  
  font-size: 2em;
  
  /* 令关闭按钮呈正方形显示 */
  height: 1em;          
  width: 1em;           
  
  /* 强制文本溢出元素并隐藏文本 */
  text-indent: 10em;      
  overflow: hidden;       
  
  background-color: transparent;
}

.modal-close::after {
  position: absolute;
  line-height: 0.5;
  top: 0.2em;
  left: 0.1em;
  text-indent: 0;
  content: "\00D7";  /* 添加 Unicode 字符 U+00D7(乘号) */
}

这段代码将按钮设置为 1em 大小的正方形,text-indent 属性则用于将文字推到右边溢出按钮。具体的移动量没有限制,只要能大过按钮的宽度就行。由于 text-indent 是继承属性,需要在 ::after 上重置为 0,以免和文字内容一同缩进。

而此时的伪类元素(译注:即 .modal-close::after)是绝对定位的,并且因为它表现得像按钮的子元素,所以按钮这个定位元素就成了该伪类元素的包含块。为防止伪元素过高,行高 line-height 的值不能太大,再配合 topleft 属性,就实现了按钮的居中对齐。这里的精确值是反复试错后的结果,建议您也在自己的浏览器开发者工具里多试试,看看它们是如何影响定位的。

绝对定位可谓是众多定位类型里颇具实力的重量级选手(heavy hitter)。它常常与 JavaScript 配合,出没于弹出菜单(popping up menus)、工具提示(tooltips)以及消息框(“info” boxes)中。接下来将演示绝对定位在构建下拉菜单时的用法,但在此之前,需要先介绍一下它的另一个搭档:相对定位(relative positioning)

6.3 相对定位

Relative positioning

相对定位可能是最不被理解的定位类型。元素首次设置 position: relative 后,通常页面上看不到任何变化。被相对定位的元素及其周围的所有元素,都还保持着原来的位置(尽管某些元素可能会跑到另一些元素前面,这个问题稍后讨论)。

如果再加上与 inset 相关的属性,元素就会从原位置移走,但周围元素的位置却保持不变。如图 6.4 所示的四个行内块级(inline-block)元素,若给第二个元素指定三个额外属性:position: relative; top: 1em; left: 2em,该元素将从初始位置移走,而其余元素则不受影响。它们依旧遵循着常规文档流的行为模式,围绕在被移走元素的初始位置周围。

图 6.4 第二个元素在相对定位的作用下发生移动

图 6.4 第二个元素在相对定位的作用下发生移动

声明 top: 1em 将元素从原来的顶部边缘下移了 1em;而 left: 2em 则将元素从最初的左边缘右移了 2em。这可能导致该元素与其下方或旁边的其他元素发生重叠。元素定位时,属性值也可以取负值,如 bottom: -1em 可以令元素下移 1em,效果上与 top: 1em 相同。

注意

与固定定位和绝对定位不同,设置了相对定位的元素,无法使用 inset 相关的属性来控制其大小。这些值只能让元素上下左右移动。可以用 top 或者 bottom,但它们不能一起使用(否则 bottom 将被忽略);同理,可以用 left 或者 right,但它们也不能一起使用(否则 right 将被忽略)。

使用这些属性来调整相对元素(relative element)的位置偶尔也很管用,比如对摆放位置进行微调。但这并非相对定位最主要的用法;更常见的使用场景,是通过设置 position: relative 来为其内部的绝对定位元素(absolutely positioned element)创建一个包含块(containing block)。

6.3.1 创建下拉菜单

Creating a dropdown menu

接下来将用相对定位与绝对定位来创建一个下拉菜单。初始状态下,该菜单是一个普通的矩形按钮;当用户点击它时,则会弹出一个链接列表,如图 6.5 所示。

图 6.5 下拉菜单

图 6.5 下拉菜单效果图

该菜单的 HTML 标记如代码清单 6.5 所示。将属于菜单的部分添加到示例页 <main class="container"> 元素的开头。该代码包含了一个容器元素,用来设置里面的内容居中对齐,并与页面顶部横幅中的内容左对齐。而菜单下面的 <h1> 元素则是为了演示下拉菜单的弹框(popup)将出现在页面其他内容前方的效果。

代码清单 6.7 添加下拉菜单的 HTML 标记

<main class="container">
  <nav>
    <div class="dropdown" id="dropdown"><!-- 下拉菜单的容器 -->
      <!-- 切换按钮始终保持可见 -->
      <button type="button" class="dropdown-toggle" 
        id="dropdown-toggle">Main Menu</button>
      <!-- 随菜单的打开/关闭而显示/隐藏的 div 元素 -->
      <div class="dropdown-menu">
        <ul class="submenu">                           
          <!--<li><a href="/">Home</a></li>-->
          <li><a href="/coffees">Coffees</a></li>      
          <li><a href="/brewers">Brewers</a></li>      
          <li><a href="/specials">Specials</a></li>    
          <li><a href="/about">About us</a></li>       
        </ul>                                          
      </div>                                           
    </div>
  </nav>

  <h1>Wombat Coffee Roasters</h1>
</main>

下拉菜单的容器包含两个子元素:一个始终显示的切换按钮,以及一个会随下拉框的开合而显示与隐藏的下拉菜单。由于下拉菜单会设置绝对定位,因此它的出现并不会影响最初的页面布局。换言之,下拉菜单将出现在其他内容的前方。

而通过按下按钮来切换菜单的展开与关闭的逻辑,则需要一些 JavaScript 脚本的配合,如代码清单 6.8 所示。将它们更新到页面,实现这一功能。

代码清单 6.8 控制下拉菜单打开与关闭的 JavaScript 代码

<script type="text/javascript">
  const dropdownToggle = document.getElementById("dropdown-toggle");
  const dropdown = document.getElementById("dropdown");
 
  dropdownToggle.addEventListener("click", function (event) {
    dropdown.classList.toggle("is-open");
  });
</script>

这段代码与前面构建的模态对话框有些类似,通过按钮的点击实现在下拉菜单中添加或删除一个 is-open 类;基于该类的 CSS 代码则会同步切换对应的效果。

接着再给下拉菜单的容器设置相对定位,为内部绝对定位的菜单建立包含块。根据代码清单 6.9 更新页面样式。

代码清单 6.9 展示呈打开状态的下拉菜单样式

.dropdown {
  display: inline-block;
  position: relative;  /* 创建包含块 */
}
 
.dropdown-toggle {
  padding: 0.5em 1.5em;
  border: 1px solid #ccc;
  background-color: #eee;
  border-radius: 0;
}
 
.dropdown-menu {
  display: none;  /* 设置菜单初始隐藏 */
  position: absolute;
  left: 0;
  top: 2.1em;  /* 将菜单放到下拉菜单按钮的下方 */
  inline-size: max-content;
  min-inline-size: 100%;
  background-color: #eee;
}
 
.dropdown.is-open .dropdown-menu {
  display: block;  /* 菜单可用时予以显示 */
}
 
.submenu {
  padding-inline-start: 0;
  margin: 0;
  list-style-type: none;
  border: 1px solid #999;
}
 
.submenu > li + li {
  border-top: 1px solid #999;
}
 
.submenu > li > a {
  display: block;
  padding: 0.5em 1.5em;
  background-color: #eee;
  color: #369;
  text-decoration: none;
}
 
.submenu > li > a:hover {
  background-color: #fff;
}

这样,当点击主菜单的切换按钮时,其下方就会弹出下拉菜单;再次点击切换按钮,下拉菜单就关闭了。值得一提的是,类似功能也可以通过伪类 :hover 来实现,但该方案对键盘导航及触屏设备不太友好(如果此时打开模态对话框,您可能会看到模态框莫名其妙地跑到下拉菜单后面去了。不要紧,这个问题很快就会解决。)。

给绝对定位的 dropdown-menu 设置 left: 0,实现了其左边与整个容器左边对齐;而 top: 2.1em 则将其上边缘放置到按钮的下方(算上内边距与边框,切换按钮大约高 2.1em)。将 min-inline-size 的值设为 100% ,确保了其宽度至少与下拉菜单的容器宽度相当(该容器宽度则由其内部元素宽度、即切换按钮的实际宽度决定)。最后通过 submenu 类指定下拉菜单的具体样式。

注意

当下拉菜单位于屏幕右边缘时,通常会使用 right: 0 而非 left: 0 来进行定位。因为这样一来,宽度超过容器的菜单项则会紧贴按钮右边缘,同时向左延展至切换按钮的左侧,而不是溢出视口的右边缘。

构建页面时,可访问性是一个重要的考量因素,对于下拉菜单这样的交互元素(interactive elements)而言更是至关重要。即使再不济,至少也要确保用户能完全通过键盘实现页面交互。本例中的下拉菜单可以使用 Tab 健定位到切换按钮,并通过 Space 空格键切换菜单的打开与关闭。当菜单打开时,您还可以继续使用 Tab 健循环浏览菜单项。(而在苹果操作系统 Mac OS 上,可能还需要再系统偏好设置中打开键盘导航(enable keyboard navigation))。关于构建考虑页面可访问性的下拉菜单的更多信息,详见:https://www.webaxe.org/accessible-custom-select-dropdowns/

DIY 发散

最后讨论页面可访问性的问题时,作者顺便提到了一个小功能:下拉菜单打开时,可以通过 Tab 健循环浏览菜单项。然而该功能并没有在新版示例页中有所体现。根据提供的示例页 listing-6.09.html,用 Tab 下翻到最后一个菜单项或用 Shift + Tab 上翻到首个菜单项时,页面并不会自动循环浏览(最新进展:目前该问题已由原书作者确认,并表示该问题会在下一版中进行更正,回复原文:We’ll fix this in our next update.)。
要实现上述功能,还应该监听页面当前被激活的元素在菜单项中的位置:顺序浏览到最后一个时,下一次 Tab 让第一个菜单项得焦点;逆序浏览至首个菜单项时,则下一次 Tab 让最后一个子项得焦点。根据这个思路,其中一个经实测的 JS 版本如下:

// ... keep the same for handling the toggle of 'is-open' class
// use Tab to cycle through the menu items
const firstItem = dropdown.querySelector('li:first-of-type > a');
const lastItem =  dropdown.querySelector('li:last-of-type > a');
document.addEventListener("keydown", function (event) {
  const {key, shiftKey} = event;
  if (dropdown.classList.contains('is-open')) {
    if(key === 'Tab'){ 
      const {activeElement: active} = document;     
      if (shiftKey) {  // Shift + Tab
        if (active === firstItem) {
          event.preventDefault();
          lastItem.focus();  // Focus the last item
        }
      } else {  // Tab
        if (active === lastItem) {
          event.preventDefault();
          firstItem.focus();  // Focus the first item
        }
      }
    }
  }
});

上述代码的 firstItemlastItem 放到 keydown 事件逻辑的外面,是基于下拉菜单项数量和内容均固定的情况,即静态页面下的处理。如果下拉菜单是动态构建的(如从后端查询数据并在前端动态渲染),则最好放到第 7 行到第 9 行之间。作为必要的妥协,改到里面后,页面每次触发 keydown 事件都会执行 DOM 查询,性能上估计会差一点点,尤其是在频繁触发的情况下。适合放到哪里,得根据具体情况而定。

6.3.2 创建 CSS 三角形

Creating a CSS triangle

下拉菜单距离完美还差最后一步。虽然已经实现了基本功能,但用户可能没法一眼看出主菜单按钮(即“Main Menu”字样的切换按钮)下方还有隐藏内容。这时就需要给按钮加上一个向下的小箭头,告诉用户在它下方还有更多内容可以浏览。

我们可以通过元素边框的一个小技巧来绘制一个三角形,并以此充当一个向下指的箭头。具体来说,就是利用切换按钮的伪元素 ::after 来绘制三角形,然后设置绝对定位,将它放到按钮的右侧。

大多数情况下,我们给一个元素设置的边框都比较细,通常给个 1px2px 就差不多了;但如果把边框设置为如图 6.6 所示的粗细会怎么样呢?图中对每一侧的边框都设置了具有强烈反差感的颜色,以便区分每条边的边界和起始位置:

图 6.6 带粗边框样式的示例元素
图 6.6 带粗边框样式的示例元素

注意观察四个角上两条边的边缘相接的地方:这里形成了一个对角边(diagonal edge)。此时再观察一下,将元素的宽高缩减到 0 会是什么效果。如图 6.7 所示,所有的边框都汇聚到了一起,并最终在元素正中位置相遇了:

图 6.7 元素宽高为零时,每条边都变成了一个三角形
图 6.7 元素宽高为零时,每条边都变成了一个三角形

此时,元素边框的每一侧都呈三角形:上边框朝下指,右边框向左指,以此类推。基于这样的观察,我们就可以利用其中一条边框作为三角形,然后将其余各边设置为透明来绘制需要的箭头符号。一个元素如果左右边框都透明,只留一个可见的上边框,最终效果则会如图 6.8 所示,形成一个简单的三角形。

图 6.8 利用元素上边框实现的 CSS 三角形效果
图 6.8 利用元素上边框实现的 CSS 三角形效果

按照上述思路给伪元素 .dropdown-toggle::after 设置样式,形成一个 CSS 三角形。然后使用绝对定位将它放置到合适的位置。根据如下样式代码更新页面:

代码清单 6.10 对下拉按钮上的三角形设置绝对定位的样式代码

.dropdown-toggle {
  padding: 0.5em 2em 0.5em 1.5em;  /* 增加按钮元素的右内边距,为箭头标记预留足够空间 */
  border: 1px solid #ccc;
  background-color: #eee;
  border-radius: 0;
}

.dropdown-toggle::after {
  content: "";
  
  /* 将元素放置在切换按钮的右边 */
  position: absolute;
  right: 1em;               
  top: 0.9em;               
  
  /* 将上边框做成一个向下指的箭头 */
  border: 0.3em solid;                            
  border-color: black transparent transparent;
}

.dropdown.is-open .dropdown-toggle::after {
  top: 0.6em;
  border-color: transparent transparent black;  /* 下拉菜单打开时,箭头向上指 */
}

上述样式中,伪元素由于没有内容(译注:content: ""),所以也不会渲染宽高;利用 border-color 属性的简写形式,将上边框的颜色设置为黑色,左右两边及下边框的均设为透明,从而构建出一个向下的箭头;元素 .dropdown-toggle 的右边用内边距预留出足够空间,以便放置三角形。最终效果如图 6.9 所示:

图 6.9 带箭头标记的下拉菜单按钮效果

图 6.9 带箭头标记的下拉菜单按钮效果

打开菜单,箭头方向应该反转朝上,表示菜单可以收拢关闭。对 top 属性值做微调(范围约在 0.9em0.6em 左右),让朝上指时的箭头看起来跟向下指时处于同一位置即可。

此外,箭头效果的实现也可以考虑使用图片(image)或背景图(background image),但是短短几行 CSS 代码就能免去不必要的网络请求,何乐而不为呢?当然也可以在 HTML 中嵌入一个轻量的 SVG,但项目中要是没有引入过什么图标集的话,本例介绍的这种只用 CSS 的实现方案会更省心。别看它只是个小小的箭头,像这样轻量小巧又美观的点缀效果,也能实实在在为您的应用与网站增色不少。

这项技术还可以用来构建其他复杂的形状,比如梯形、六边形以及星型。想要查看用 CSS 构建的各种形状,请参阅 CSS-Tricks 网站的专题文章:The Shapes of CSS。请注意,这些形状通常也不是必须要掌握的知识点,尤其是在 SVG 图标盛行的当下。它们只是为了给大家展示 CSS 具备的各种能力。

译注

本节原文最后给出的文章链接在 CSS-Tricks 网站已失效(即 https://css-tricks.com/examples/ShapesOfCSS/),为此官网上很早就有读者反馈该问题,但一直没有公布新的 URL。我学到这里时碰巧搜到了这篇文章,发现是今年 4 月才更新的,有机会给各位转发一下,免得今后又过期了。此外,更新文章链接时还有个意外彩蛋,也一并分享给各位。更新这篇文章的,正是 CSS-Tricks 网站的创始人 Chris Coyier

此人来头可不小,不仅一手创办并成功运营 CSS-Tricks 长达 15 年之久(2007 年创办,2022 年被知名 IDC 服务商 DigitalOcean 收购),同时也是 CodePen 网站的联合创始人之一。感兴趣的朋友可以去他的个人网站 凑凑热闹,学习学习这位仍活跃在 CSS 一线的知名大咖是怎么做自我营销的。里面有很多漂亮的设计可供参考,也是个不错的学习园地。Chris Coyier 有句话给我印象很深:

I’m big on the power of writing as a way to think better and improve yourself.

我极为看重写作的力量,那是一种能帮我更好地思考并提升自我的有效途径。(深以为然!)

气氛都烘托到这儿了,怎么也得来张大头贴吧:
Chris Coyier

Chris Coyier 大头贴

6.4 堆叠上下文与 z-index

Stacking contexts and z-index

定位技术(Positioning)固然实用,但更重要的是弄明白它会带来什么样的意外情况。一个元素自从脱离文档流后,之前由文档流负责的工作从此也将由您来全面接管。

比如,要时刻确保该元素不会意外跑到浏览器视口(viewport)的外面,让用户找不着它;其次,必须保证该元素不会意外挡住页面上的重要内容。

最后还要考虑元素堆叠(stacking)方面的问题。在同一页面定位多个元素时,很可能会遇到两个不同的定位元素发生重叠的情况,并且偶尔还会发现它们并没有乖乖按照我们预想的方式进行重叠。其实本章的示例已经有意设置了这样的问题场景,以便进一步演示该如何处理。

根据之前示例页面的设计需求,点击页面顶部的注册按钮(即 “Sign up” 字样的按钮)就能打开一个模态对话框。要是把下拉菜单相关的 HTML 标记放到模态框的源码后面,最终效果就会如下图 6.10 所示,下拉菜单意外挡在了模态对话框的前面:

图 6.10 模态框错误地出现在下拉菜单的后面

图 6.10 模态框错误地出现在下拉菜单的后面

解决这个问题有很多种方案。在此之前,有必要先了解一下浏览器确定元素堆叠顺序的基本原理。为此,需要进一步考察一个页面在浏览器中的渲染过程。

6.4.1 理解渲染过程与堆叠顺序

Understanding the rendering process and stacking order

在浏览器将 HTML 解析为 DOM 时,它还会同步创建一个新的树形结构,称为 渲染树(render tree。该渲染树不仅体现了每个元素的视觉样式和位置,同时也决定着浏览器 绘制(paint 这些元素的顺序。该顺序 极其重要:一旦发生重叠,后绘制的元素就会出现在先绘制的元素上。

通常情况下(即使用 CSS 定位前),该绘制顺序由元素在 HTML 中出现的源码顺序决定。以如下 HTML 标记的这三个元素为例:

<div>one</div>
<div>two</div>
<div>three</div>

它们相互间的堆叠行为将如图 6.11 所示。这里使用了负的外边距来让元素重叠,但并没有设置任何定位。可以看到,源码位置靠后的元素绘制在了位置靠前的元素上:

图 6.11 正常情况下三个元素的堆叠效果

图 6.11 正常情况下三个元素的堆叠效果

而设置定位后,情况就不同了。浏览器会优先绘制所有未被定位的元素(non-positioned elements),然后再绘制已定位的元素。默认情况下,已定位的所有元素都会出现在尚未定位的元素前面(to the front)。如图 6.12 所示,给前两个元素加上 position: relative 后,它们就绘制到前面去了,覆盖了静态定位下的第三个元素,尽管元素在 HTML 中的源码顺序并未改变。

注意,在定位的这两个元素中,第二个元素还是绘制在了第一个元素的前面。虽然定位元素都被放到了前面,但它们之间基于源码的重叠关系仍旧不变。

图 6.12 已定位的元素绘制在了静态元素的前面

图 6.12 已定位的元素绘制在了静态元素的前面

也就是说,此时的示例页中,模态框和下拉菜单都会出现在静态内容之前(符合预期),但源码里后出现的元素还是会绘制到先出现的元素前面。要解决这个问题,有一种方案是将 <div class="modal"> 及其所有内容全部移动到下拉菜单 HTML 源码的后面。

通常情况下,模态框都会放到网页内容的最后,即 </body> 关闭标签的前面;大多数构建模态框的 JavaScript 库也会自动照这样处理。因为模态框用的是固定定位,所以无论其 HTML 标记在哪儿,最终都会被定位到屏幕正中。

移动源码位置这招对于固定定位元素来讲倒是没啥影响,但要是换作相对定位元素或者绝对定位元素,这招就失灵了。因为相对定位元素依赖于文档流,而绝对定位元素依赖于它最近的那个祖先定位元素(译注:即 containing block 包含块。详见 6.2 节内容)。这就需要我们另辟蹊径来控制此类元素的堆叠行为。于是就轮到 CSS 中的 z-index 属性闪亮登场了!

6.4.2 用 z-index 控制堆叠顺序

Manipulating stacking order with z-index

z-index 属性的值可以为任意整数(正负数均可)。这里的 z 表示的是笛卡尔 X-Y-Z 坐标系(译注:即三维直角坐标系)里的深度方向。z-index 值较高的元素会出现在该值较低的元素的前面。属性值为负数的元素则会出现在静态元素后面。

使用 z-index 属性便是解决页面堆叠问题的第二种方案。该方案不要求修改 HTML 的结构,只需令元素 modal-backdropz-index 值为 1、且元素 modal-bodyz-index 值为 2 即可(这样就确保了模态框的主体部分位于蒙层的前方)。根据代码清单 6.11 更新本地样式表:

代码清单 6.11 给模态框加上 z-index 使其出现在下拉菜单前面

.modal-backdrop {
  position: fixed;
  inset: 0;
  background-color: rgba(0 0 0 / 0.5);
  z-index: 1;  /* 将模态框的蒙层置于未设置 z-index 的元素前方 */
}

.modal-body {
  position: fixed;
  inset-block: 3em;
  inset-inline: 20%;
  padding: 2em 3em;
  background-color: white;
  overflow: auto;
  z-index: 2;  /* 将模态框主体提到蒙层的前方 */
}

z-index 看似简单,使用时却有两个小陷阱(gotchas)务必要当心:一是 z-index 只对定位元素生效,无法控制静态元素的堆叠顺序;其二,一旦给定位元素设置了 z-index,就必然会牵涉到另一个核心概念,称之为 堆叠上下文(stacking context

6.4.3 深入理解堆叠上下文

Understanding stacking contexts

所谓的 堆叠上下文(stacking contexts,是由浏览器一同绘制(painted together)的一个或多个元素组成。其中的一个元素会作为堆叠上下文的根元素(root)。比如,当给一个已设置定位的元素加上 z-index 时,它就成了该堆叠上下文的根元素;其所有后代元素则相应变为该堆叠上下文的一部分。

事实上,将堆叠上下文里的所有元素一同绘制会造成严重的后果:堆叠上下文以外的元素将永远无法叠放在该上下文内部的任意两个元素之间。也就是说,如果一个元素叠放在了某个堆叠上下文的前面,那么该元素将不会被后方堆叠上下文里任一元素所覆盖;同理,如果一个元素被放在了堆叠上下文的后面,那它将永无翻身之日,堆叠上下文中的任一元素无论怎么设置都不会让该元素挡在前面。

这么说可能有点绕,下面结合一个最简单的应用场景来演示说明。新建一个 HTML 页面,并按照代码清单 6.12 给出的 HTML 标记更新页面:

图 6.12 堆叠上下文示例 HTML 标记

<div class="box one positioned">
  one
  <div class="absolute">nested</div>
</div>
<div class="box two positioned">two</div>
<div class="box three">three</div>

这段代码包含三个内容盒子,其中有两个被设置为定位元素,其 z-index 的值均为 1;而位于第一个定位元素中的绝对定位元素,其 z-index 的值为 100。虽然后者的属性值很大,但最终还是被渲染到了第二定位元素的后面,只因其父元素(即第一个定位元素)形成的堆叠上下文位于第二个内容盒的 后方(如图 6.13 所示)。

图 6.13 整个堆叠上下文相对于页面上其他元素进行叠放
图 6.13 整个堆叠上下文相对于页面上其他元素进行叠放

上述效果的 CSS 样式如代码清单 6.10 所示。将它们更新到本地页面。其中大部分样式用于设置尺寸大小与颜色,以便区分堆叠顺序。元素间的重叠效果还是用负的外边距实现。当中最核心的代码,莫过于给每个元素设置 positionz-index 的部分了:

代码清单 6.13 创建堆叠上下文的样式代码

body {
  margin: 40px;
}
 
.box {
  display: inline-block;
  width: 200px;
  line-height: 200px;
  text-align: center;
  border: 2px solid black;
  background-color: #ea5;
  margin-left: -60px;  /* 实现元素重叠效果 */
  vertical-align: top;
}
 
.one { margin-left: 0; }
.two { margin-top: 30px; }
.three { margin-top: 60px; }

/* 每个定位元素都分别创建了一个 z-index 值为 1 的堆叠上下文 */
.positioned {
  position: relative;
  background-color: #5ae;
  z-index: 1;
}
 
.absolute {
  position: absolute;
  top: 1em;
  right: 1em;
  background-color: #fff;
  border: 2px dashed #888;
  z-index: 100;  /* z-index 只对该元素所在的堆叠上下文中的堆叠顺序生效 */
  line-height: initial;
  padding: 1em;
}

根据堆叠上下文的相关概念,叠放在第二个盒子下面的第一个盒子其实是它所在堆叠上下文的根元素。正因如此,即便当中的绝对定位元素给到一个很高的 z-index 值,依旧无法覆盖在第二个盒子上面。大家不妨在浏览器的开发者工具里试验一下,以便更好地理解这种堆叠关系。试着改改每个元素的 z-index 值,看看效果究竟如何。

注意

添加 z-index 值并非创建堆叠上下文的唯一途径,比如 opacity 属性,在属性值小于 1 时也能创建,类似的属性还有 transformfilter 等等。此外,固定定位和粘性定位(sticky positioning,将在本章 6.5 节介绍)即便不指定 z-index 值也能创建一个堆叠上下文。而 HTML 文档的根节点(document root,即 <html> 元素)则会为整个页面创建一个 顶级堆叠上下文(top-level stacking context)

堆叠上下文中的所有元素会按照以下顺序,从后往前依次叠放:

用变量来跟踪 z-index 的值

如果不根据页面各组件的优先级划分出条理清晰的堆叠顺序,那么页面样式表很容易演变为一场 z-index 混战。没有清晰的说明,开发人员在给一个模态框之类的元素设计样式时,为了防止被其他元素遮挡,很可能会指定一个高得离谱的 z-index,比如 999999。像这样反复折腾几遍后,后续给新组件指定 z-index 值就只能凭感觉和运气了。

为避免情况失控,可以使用 CSS 的自定义属性(custom properties,详见第 2 章 2.6 节相关内容)来统一管理:将所有 z-index 值定义为变量并放到同一个地方集中管理(如下所示)。这样就能一目了然地看出那些元素在前、那些元素在后了:

--z-loading-indicator: 100;
--z-nav-menu:          200;
--z-dropdown-menu:     300;
--z-modal-backdrop:    400;
--z-modal-body:        410;

步长增量建议预留为 10 或者 100,以便今后根据实际情况进行增补。

要是发现 z-index 的行为不符合预期,记得在 DOM 数中定位到该元素的祖先节点,直至锁定堆叠上下文的根节点;然后调整其 z-index 属性,将整个堆叠上下文前移或后置。此外,遇到多个堆叠上下文相互嵌套的情况,操作时务必慎之又慎。

当页面很复杂时,可能很难准确判断是哪个堆叠上下文出的问题。因此,在创建之初就得多加小心,防患于未然。没有特殊理由最后不要随意创建堆叠上下文,尤其是遇到元素包含了很大一块内容的时候,这一点尤为重要。再者,要尽量将模态框这样的独立定位元素放到 DOM 结构的最外层、紧邻关闭标签 </body> 之前,这样一来就没有什么外部的堆叠上下文能束缚住它们了。

一些开发人员会忍不住给页面大量元素设置定位。一定要克制这种冲动。定位用得越多,网页就越复杂,也就越难以调试。如果已经定位了大量元素,不妨回过头来重新评估一下当前的状况,尤其是当发现自己很难调试出想要的效果时,一定要反思。如果可以用别的方案实现当前的布局效果,应当优先选用那些方案。

相比手动设置定位来实现某个布局,如何能够依靠文档流实现同样的效果,那么浏览器会帮我们处理好很多边界情况。切记:CSS 定位会让元素移出文档流(positioning takes elements out of the document flow)。一般来说,只有在需要将元素叠放到其他元素之前时,才会考虑 CSS 定位。

6.5 粘性定位

Sticky positioning

最后一种定位类型是 粘性定位(sticky positioning。粘性定位有点像相对定位和固定定位的结合体:正常情况下,元素会随着页面滚动;当到达屏幕的特定位置后,继续滚动屏幕则会“锁定”在该位置。其最常见的应用场景为侧边栏导航。

回到前面那个含有模态对话框以及下拉菜单的示例页,本节将基于该页面添加一个粘性定位的右侧边栏,实现如图 6.14 所示的双列布局效果:

图 6.14 粘性定位的侧边栏在初始状态下为普通定位

【图 6.14 粘性定位的侧边栏在初始状态下为普通定位】

页面刚加载时,侧边栏位置一切正常。页面一滚动,它也跟着滚动到某个位置;待到侧边栏即将离开视口时,则会锁在该位置不动;而当页面其余部分继续滚动时,侧边栏又像一个设置了固定定位的元素,继续停留在屏幕上,效果如图 6.15 所示:

图 6.15 侧边栏继续“固定”在视口内的效果

【图 6.15 侧边栏继续“固定”在视口内的效果】

为了方便演示,页面结构需略作调整,再重新定义一下两栏。根据代码清单 6.14 更新示例页。该代码将之前的内容(即下拉菜单与标题栏)放在了左侧边栏,同时在右侧边栏放置一个类名为 “affix”(译注:这里的 affix 可以理解为“附件、附加物”)的菜单。

代码清单 6.14 将页面改为带侧边栏的双列布局

<main class="container">
  <div class="col-main"><!-- 将现有内容用 col-main 包起来,作为正文栏 -->
    <nav>
      <div class="dropdown">
        <div class="dropdown-label">Main Menu</div>
        <div class="dropdown-menu">
          <ul class="submenu">
            <!--<li><a href="/">Home</a></li>-->
            <li><a href="/coffees">Coffees</a></li>
            <li><a href="/brewers">Brewers</a></li>
            <li><a href="/specials">Specials</a></li>
            <li><a href="/about">About us</a></li>
          </ul>
        </div>
      </div>
    </nav>
    <h1>Wombat Coffee Roasters</h1>
  </div>

  <aside class="col-sidebar"><!-- 再加一个侧边栏元素 -->
    <div class="affix"><!-- 新的侧边栏里面放一个 affix 元素 -->
      <ul class="submenu">
        <li><!--<a href="/">Home</a></li>-->
        <li><a href="/coffees">Coffees</a></li>
        <li><a href="/brewers">Brewers</a></li>
        <li><a href="/specials">Specials</a></li>
        <li><a href="/about">About us</a></li>
      </ul>
    </div>
  </aside>
</main>

接着更新页面样式,将容器设置为弹性容器,并指定两栏的列宽。本例复用了下拉菜单中的子菜单样式,您也可以给侧边栏添加其他想要的元素与样式。根据代码清单 6.15 更新本地样式表。

代码清单 6.15 创建一个两栏布局及粘性定位的侧边栏的 CSS 样式

.container {
  display: flex; /* 将容器设置为两栏布局的弹性容器 */
  width: 80%;
  max-width: 1000px;
  margin: 1em auto;
  min-height: 100vh; /* 特意给容器设置高度 */
}
 
.col-main {
  flex: 1 80%;  /* 给两栏布局 */
}
 
.col-sidebar {
  flex: 20%;  /* 给两栏布局 */
}
 
.affix {
  /* 给侧边栏菜单设置粘性定位,放在距视口顶部 1em 的位置 */
  position: sticky;
  top: 1em;
}

以上样式主要用于设置两栏布局。布局搞定后,affix 元素的粘性定位声明只需要两句就行了。其中 top 值指定了该元素最终固定的位置——距离视口顶部 1em

粘性定位元素永远不会超出父元素(本例中即为 col-sidebar 元素)的范围,随着页面的滚动,col-sidebar 会一直正常滚动,而 affix 元素则会在滚动到指定位置后停下来。如果继续滚动得足够远,粘性元素还会自行解锁恢复滚动。这种情况只在父元素的底边到达粘性菜单元素的底边时才会发生。注意,要让粘性元素能按预期粘性定位无误,父元素就必须高于粘性元素——这也是我给弹性容器手动设置 min-height 来增加其高度的根本原因。当父元素快要完全滚出视口时,一旦父元素底边碰到了粘性元素,那么该粘性元素将再次恢复滚动。

6.6 本章小结

Summary