气球(弹出)

Avatar of John Rhea
John Rhea

DigitalOcean 为您的旅程的每个阶段提供云产品。立即开始使用 $200 免费信用额度!

我一直对我们仅仅使用 HTML 和 CSS 就能实现多少功能着迷。Popover API 的新交互功能再次证明了我们仅用这两种语言就能走多远。

您可能已经看到其他教程展示了 Popover API 的功能,但这篇文章更像是一种“无情地将它打败”的文章。我们会为它添加一点更多活力音乐,就像气球一样……如果你愿意的话,就用“弹出”来形容。

我做了一个游戏——当然,只用 HTML 和 CSS——依赖于 Popover API。你的任务是在一分钟内尽可能多地弹出气球。但要小心!有些气球(就像咕噜姆说的那样)是“狡猾”的,会触发更多气球。

我巧妙地把它叫做气球(弹出),我们一步一步地一起制作它。完成后,它会看起来像(好吧,完全像)这样

处理popover 属性

任何元素都可以是弹出窗口,只要我们用popover 属性对其进行格式化。

<div popover>...</div>

我们甚至不需要为popover 提供值。默认情况下,popover 的初始值为auto,并使用规范中称为“轻微关闭”的方式。这意味着可以通过点击弹出窗口外部的任何位置来关闭弹出窗口。当弹出窗口打开时,除非它们嵌套,否则页面上的任何其他弹出窗口都会关闭。自动弹出窗口是这样的相互依赖的。

另一个选项是将popover 设置为manual

<div popover=“manual”>...</div>

…这意味着元素是手动打开和关闭的——我们必须点击一个特定的按钮才能打开和关闭它。换句话说,manual 会创建一个固执的弹出窗口,只有在点击了正确的按钮才会关闭,并且完全独立于页面上的其他弹出窗口。

使用<details> 元素作为起点

使用 Popover API 制作游戏的一个挑战是,你不能在一个已经打开弹出窗口的页面上加载页面……如果你想用 HTML 和 CSS 制作游戏,那么 JavaScript 就无法避免这个问题。

这时就要用到<details> 元素。与弹出窗口不同,<details> 元素可以默认打开

<details open>
  <!-- rest of the game -->
</details>

如果我们采用这种方法,我们就可以显示一组按钮(气球),然后通过关闭<details> 来“弹出”所有这些按钮,直到最后一个气球。换句话说,我们可以将起始气球放到一个打开的<details> 元素中,这样它们在加载页面时就会显示出来。

这是我正在讨论的基本结构

<details open>
  <summary>🎈</summary>
  <button>🎈</button>
  <button>🎈</button>
  <button>🎈</button>
</details>

这样,我们可以点击<summary> 中的气球来关闭<details>,并“弹出”所有按钮气球,留下一个气球(最后的<summary>(我们稍后会解决如何删除它)。

您可能会认为,<dialog> 对我们的游戏来说是一个更有语义性的方向,您是对的。但<dialog> 有两个缺点,让我们无法在这里使用它

  1. 关闭在页面加载时打开的<dialog> 的唯一方法是使用 JavaScript。据我所知,没有可以放在游戏中的关闭<button> 来关闭在加载时打开的<dialog>
  2. <dialog> 是模态的,会在它们打开时阻止点击其他内容。我们需要允许玩家在<dialog> 外部弹出气球,以便战胜计时器。

因此,我们将使用一个<details open> 元素作为游戏的顶级容器,并使用一个普通的<div> 作为弹出窗口本身,即<div popover>

目前,我们只需要确保所有这些弹出窗口和按钮都连接在一起,以便点击按钮打开弹出窗口。您可能已经在其他教程中学习过这一点,但我们需要告诉弹出窗口元素它需要响应哪个按钮,然后告诉按钮它需要打开哪个弹出窗口。为此,我们给弹出窗口元素一个唯一的 ID(所有 ID 应该都是唯一的),然后在<button> 上用popovertarget 属性引用它

<!-- Level 0 is open by default -->
<details open>
  <summary>🎈</summary>
  <button popovertarget="lvl1">🎈</button>
</details>

<!-- Level 1 -->
<div id="lvl1" popover="manual">
  <h2>Level 1 Popup</h2>
</div>

这是所有内容连接在一起后的想法

打开和关闭弹出窗口

在最后一个演示中还需要做一些工作。迄今为止,游戏的一个缺点是,点击<button> 弹出窗口会打开更多弹出窗口;再次点击同一个<button> 就会让它们消失。这使得游戏过于简单。

我们可以通过在<button> 上设置popovertargetaction 属性(不,HTML 规范作者并不关心简洁性)来分离打开和关闭行为。如果我们将属性值设置为showhide<button> 将只对该特定弹出窗口执行一个动作。

<!-- Level 0 is open by default -->
<details open>
  <summary>🎈</summary>
  <!-- Show Level 1 Popup -->
  <button popovertarget="lvl1" popovertargetaction="show">🎈</button>
  <!-- Hide Level 1 Popup -->
  <button popovertarget="lvl1" popovertargetaction="hide">🎈</button>
</details>

<!-- Level 1 -->
<div id="lvl1" popover="manual">
  <h2>Level 1 Popup</h2>
  <!-- Open/Close Level 2 Poppup -->
  <button popovertarget="lvl2">🎈</button>
</div>

<!-- etc. -->

注意,我在<div> 中添加了一个新的<button>,它被设置为目标另一个<div>,通过在它上面设置popovertargetaction 属性来弹出打开或关闭。看看“弹出”这些元素是多么具有挑战性(从好的方面来说)

为气球添加样式

现在我们需要以相同的方式为<summary><button> 元素添加样式,这样玩家就无法区分它们。请注意,我说是<summary>,而不是<details>。这是因为<summary> 是我们实际点击打开和关闭<details> 容器的元素。

大部分都是非常标准的 CSS 工作:设置背景、填充、边距、大小、边框等。但有一些重要的,并不一定直观的,需要包含的东西。

  • 首先,我们需要将list-style-type 属性设置为<summary> 元素的none,以去除指示<details> 是否打开或关闭的三角形标记。该标记非常有用,默认情况下也很好,但对于像这样的游戏,最好去除这个提示,以便更好地挑战。
  • Safari 不喜欢同样的方法。要在这里删除<details> 标记,我们需要设置一个特殊的供应商前缀伪元素,summary::-webkit-details-marker 设置为display: none
  • 如果鼠标光标能指示气球是可点击的,那就太好了,所以我们也可以在<summary> 元素上设置cursor: pointer
  • 最后一个细节是在<summary> 上设置user-select 属性为none,以防止气球——仅仅是表情符号文本——被选中。这使它们更像页面上的对象。
  • 是的,现在是 2024 年,我们仍然需要那个带前缀的-webkit-user-select 属性来支持 Safari。谢谢,苹果。

将所有这些代码放到一个.balloon 类中,我们将用它来表示<button><summary> 元素

.balloon {
  background-color: transparent;
  border: none;
  cursor: pointer;
  display: block;
  font-size: 4em;
  height: 1em;
  list-style-type: none;
  margin: 0;
  padding: 0;
  text-align: center;
  -webkit-user-select: none; /* Safari fallback */
  user-select: none;
  width: 1em;
}

气球的一个问题是,其中一些气球故意什么也不做。这是因为它们关闭的弹出窗口没有打开。玩家可能会认为他们没有点击/点击特定气球,或者游戏坏了,所以让我们在气球处于其:active 点击状态时添加一点缩放

.balloon:active {
  scale: 0.7;
  transition: 0.5s;
}

奖金:因为cursor 是一个指向食指的手,所以点击气球看起来就像用手指戳气球一样。👉🎈💥

我们分布气球的方式是另一个需要考虑的重要因素。我们无法在没有 JavaScript 的情况下随机放置它们,所以这不行。我尝试了许多方法,比如自己定义“随机”数字,这些数字被定义为可以作为乘数使用的自定义属性,但我无法在没有重叠气球或建立某种视觉模式的情况下让整体结果感觉“随机”。

我最终使用了一个方法,该方法使用一个类来将气球定位在不同的行和列中——不像 CSS Grid 或 Multicolumns,而是基于物理内边距的虚构行和列。它看起来有点像 Grid,而且不像我想要的那样“随机”,但只要没有两个气球具有相同的两个类,它们就不会彼此重叠。

我决定使用一个 8×8 的网格,但留空了第一“行”和“列”,这样气球就不会出现在浏览器的左侧和顶部边缘。

/* Rows */
.r1 { --row: 1; }
.r2 { --row: 2; }
/* all the way up to .r7 */

/* Columns */
.c1 { --col: 1; }
.c2 { --col: 2; }
/* all the way up to .c7 */

.balloon {
  /* This is how they're placed using the rows and columns */
  top: calc(12.5vh * (var(--row) + 1) - 12.5vh);
  left: calc(12.5vw * (var(--col) + 1) - 12.5vw);
}

恭喜玩家(或者不恭喜)

我们已经把大部分游戏元素都放好了,但是如果能有个胜利舞蹈弹出框,在玩家及时弹出所有气球时恭喜他们,那就太好了。

一切都要回到一个<details open> 元素。一旦该元素不再处于open 状态,游戏就应该结束,最后一步是弹出最后一个气球。所以,如果我们给这个元素一个 ID,比如 #root,我们可以创建一个条件,当它处于 :not()open 状态时,使用 display: none 来隐藏它。

#root:not([open]) {
  display: none;
}

这就是我们拥有:has() 伪选择器非常棒的地方,因为我们可以用它来选择 #root 元素的父元素,以便当 #root 关闭时,我们可以选择该父元素的子元素——一个带有 #congrats ID 的新元素——来显示一个伪弹出框,向玩家显示恭喜信息。(是的,我知道这很讽刺。)

#game:has(#root:not([open])) #congrats {
  display: flex;
}

如果我们现在开始玩游戏,我们可以在没有弹出所有气球的情况下收到胜利信息。再说一次,手动弹出框不会关闭,除非点击了正确的按钮——即使我们关闭了它祖先的 <details> 元素。

有没有办法在 CSS 中知道弹出框是否仍然打开?是的,输入 :popover-open 伪类。

:popover-open 伪类选择一个打开的弹出框。我们可以将其与之前使用的 :has() 结合使用,以防止消息在页面上仍有弹出框打开时显示。以下是将这些内容链接在一起以像and 条件语句一样工作的方式。

/* If #game does *not* have an open #root 
 * but has an element with an open popover 
 * (i.e. the game isn't over),
 * then select the #congrats element...
 */
#game:has(#root:not([open])):has(:popover-open) #congrats {
  /* ...and hide it */
  display: none;
}

现在,只有当玩家真正赢了的时候才会收到祝贺。

相反,如果玩家在计时器到期前无法弹出所有气球,我们应该通知玩家游戏已经结束。由于我们在 CSS 中没有 if() 条件语句(至少目前还没有),我们将运行一个一分钟的动画,这样这个消息就会淡入,结束游戏。

#fail {
  animation: fadein 0.5s forwards 60s;
  display: flex;
  opacity: 0;
  z-index: -1;
}

@keyframes fadein {
  0% {
    opacity: 0;
    z-index: -1;
  }
  100% {
    opacity: 1;
    z-index: 10;
  }
}

但我们不希望失败信息在显示胜利画面时触发,因此我们可以编写一个选择器,阻止 #fail 信息与 #congrats 信息同时显示。

#game:has(#root:not([open])) #fail {
  display: none;
}

我们需要一个游戏计时器

玩家应该知道自己还有多少时间可以弹出所有气球。我们可以创建一个相当“简单”的计时器,使用一个占据屏幕全宽(100vw)的元素,在水平方向上缩放它,然后将其与上面的动画匹配,使 #fail 消息淡入。

#timer {
  width: 100vw;
  height: 1em;
}

#bar {
  animation: 60s timebar forwards;
  background-color: #e60b0b;
  width: 100vw;
  height: 1em;
  transform-origin: right;
}

@keyframes timebar {
  0% {
    scale: 1 1;
  }
  100% {
    scale: 0 1;
  }
}

只有一个失败点可能会让游戏太容易,所以让我们尝试添加第二个 <details> 元素,它有一个第二个“根”ID #root2。再一次,我们可以使用 :has 来检查 #root#root2 元素是否都处于 open 状态,然后再显示 #congrats 消息。

#game:has(#root:not([open])):has(#root2:not([open])) #congrats {
  display: flex;
}

总结

剩下的唯一事情就是玩游戏了!

有趣,对吧?我相信我们可以在没有 JavaScript 的限制的情况下构建一个更强大的东西,而且我们并没有认真地进行可访问性测试,但是将 API 推到极限既有趣又具有教育意义,对吧?


我很感兴趣:你能想出哪些其他奇思妙想来使用弹出框?也许你脑海中已经有了另一个游戏,一些炫酷的 UI 效果,或者一些将弹出框与其他新兴 CSS 功能巧妙结合的方法,比如锚点定位。无论是什么,请分享出来!