Pop(over) die Ballons

Avatar of John Rhea
John Rhea am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Ich war schon immer fasziniert davon, wie viel wir mit nur HTML und CSS erreichen können. Die neuen interaktiven Funktionen der Popover-API sind ein weiteres Beispiel dafür, wie weit wir allein mit diesen beiden Sprachen kommen.

Vielleicht haben Sie schon andere Tutorials gesehen, die zeigen, was die Popover-API kann, aber dies ist eher eine Art „Ich-zwinge-sie-gnadenlos-in-die-Knie“-Artikel. Wir mischen dem Ganzen etwas mehr Pop- Musik bei, zum Beispiel mit Luftballons… ein buchstäbliches „Ploppen“ (Pop), wenn Sie so wollen.

Ich habe ein Spiel erstellt – natürlich nur mit HTML und CSS –, das auf der Popover-API basiert. Ihre Aufgabe ist es, so viele Ballons wie möglich in weniger als einer Minute platzen zu lassen. Aber Vorsicht! Einige Ballons sind (wie Gollum sagen würde) „garstig“ und lösen weitere Ballons aus.

Ich habe es clevererweise Pop(over) the Balloons genannt, und wir werden es Schritt für Schritt gemeinsam bauen. Wenn wir fertig sind, wird es ungefähr so (OK, exakt so) aussehen:

Umgang mit dem popover-Attribut

Jedes Element kann ein Popover sein, solange wir es mit dem popover-Attribut ausstatten.

<div popover>...</div>

Wir müssen popover nicht einmal einen Wert zuweisen. Standardmäßig ist der Initialwert von popover auf auto gesetzt und nutzt das, was die Spezifikation „light dismiss“ nennt. Das bedeutet, das Popover kann durch Klicken an eine beliebige Stelle außerhalb des Elements geschlossen werden. Und wenn sich das Popover öffnet, schließen sich alle anderen Popover auf der Seite, sofern sie nicht verschachtelt sind. Auto-Popover sind in dieser Hinsicht voneinander abhängig.

Die andere Option ist, popover auf den Wert manual zu setzen.

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

…was bedeutet, dass das Element manuell geöffnet und geschlossen wird – wir müssen buchstäblich auf einen bestimmten Button klicken, um es zu öffnen und zu schließen. Mit anderen Worten: manual erzeugt ein eigenwilliges Popup, das sich nur schließt, wenn man den richtigen Button drückt, und das völlig unabhängig von anderen Popovern auf der Seite ist.

Das <details>-Element als Startpunkt nutzen

Eine der Herausforderungen beim Erstellen eines Spiels mit der Popover-API ist, dass man eine Seite nicht mit einem bereits geöffneten Popover laden kann… und wenn unser Ziel ist, das Spiel nur mit HTML und CSS zu bauen, gibt es keinen Weg um JavaScript herum.

Hier kommt das <details>-Element ins Spiel. Im Gegensatz zu einem Popover kann das <details>-Element standardmäßig geöffnet sein.

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

Wenn wir diesen Weg wählen, können wir eine Reihe von Buttons (Ballons) anzeigen und sie alle bis zum allerletzten Ballon „platzen“ lassen, indem wir das <details>-Element schließen. Mit anderen Worten: Wir können unsere Startballons in ein offenes <details>-Element packen, damit sie beim Laden der Seite angezeigt werden.

Das ist die Grundstruktur, von der ich spreche:

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

Auf diese Weise können wir auf den Ballon im <summary> klicken, um das <details>-Element zu schließen und alle Button-Ballons „platzen“ zu lassen, sodass am Ende nur noch ein Ballon (das <summary>) übrig bleibt (wie wir diesen entfernen, klären wir etwas später).

Sie könnten denken, dass <dialog> eine semantischere Richtung für unser Spiel wäre, und Sie hätten recht. Aber es gibt zwei Nachteile bei <dialog>, die uns hier im Weg stehen:

  1. Die einzige Möglichkeit, ein beim Laden der Seite offenes <dialog> zu schließen, ist JavaScript. Soweit ich weiß, gibt es keinen Schließen-<button>, den wir ins Spiel einbauen können, um ein bereits offenes <dialog>-Element zu schließen.
  2. <dialog>-Elemente sind modal und verhindern das Klicken auf andere Dinge, während sie offen sind. Wir müssen den Spielern erlauben, Ballons außerhalb des <dialog>-Elements platzen zu lassen, um den Timer zu schlagen.

Daher werden wir ein <details open>-Element als Top-Level-Container des Spiels verwenden und für die Popups selbst einfache <div>-Elemente nutzen, also <div popover>.

Vorerst müssen wir nur sicherstellen, dass all diese Popover und Buttons miteinander verdrahtet sind, sodass das Klicken auf einen Button ein Popover öffnet. Das haben Sie wahrscheinlich schon in anderen Tutorials gelernt: Wir müssen dem Popover-Element sagen, dass es einen Button gibt, auf den es reagieren soll, und dem Button sagen, dass es ein Popup gibt, das er öffnen muss. Dazu geben wir dem Popover-Element eine eindeutige ID und referenzieren diese auf dem <button> mit einem popovertarget-Attribut.

<!-- 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>

Das ist das Konzept, wenn alles miteinander verknüpft ist:

Popover öffnen und schließen

In der letzten Demo gibt es noch etwas zu tun. Ein Nachteil des Spiels ist bisher, dass das Klicken auf den <button> eines popup weitere Popups öffnet; klickt man denselben <button> erneut, verschwinden sie. Das macht das Spiel zu einfach.

Wir können das Öffnen- und Schließen-Verhalten trennen, indem wir das popovertargetaction-Attribut (nein, die Autoren der HTML-Spezifikation waren nicht um Kürze bemüht) auf dem <button> setzen. Wenn wir den Attributwert entweder auf show oder hide setzen, führt der <button> nur diese eine Aktion für das spezifische Popover aus.

<!-- 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. -->

Beachten Sie, dass ich einen neuen <button> innerhalb des <div> hinzugefügt habe, der so eingestellt ist, dass er ein anderes <div> öffnet oder schließt, indem ich das popovertargetaction-Attribut absichtlich nicht gesetzt habe. Sehen Sie selbst, wie herausfordernd (auf eine gute Art) es ist, die Elemente „platzen“ zu lassen:

Styling der Ballons

Nun müssen wir die <summary>- und <button>-Elemente identisch stylen, damit ein Spieler nicht unterscheiden kann, welches welches ist. Beachten Sie, dass ich <summary> und nicht <details> gesagt habe. Das liegt daran, dass <summary> das eigentliche Element ist, auf das wir klicken, um den <details>-Container zu öffnen und zu schließen.

Das meiste davon ist Standard-CSS: Hintergründe festlegen, Padding, Margin, Größe, Rahmen usw. Aber es gibt ein paar wichtige, nicht unbedingt intuitive Dinge, die wir berücksichtigen müssen.

  • Zuerst setzen wir die Eigenschaft list-style-type auf none für das <summary>-Element, um den dreieckigen Marker loszuwerden, der anzeigt, ob <details> offen oder geschlossen ist. Dieser Marker ist normalerweise sehr nützlich, aber für ein Spiel wie dieses ist es besser, diesen Hinweis für eine größere Herausforderung zu entfernen.
  • Safari mag diesen Ansatz nicht. Um den <details>-Marker dort zu entfernen, müssen wir ein spezielles Pseudo-Element mit Vendor-Präfix verwenden: summary::-webkit-details-marker muss auf display: none gesetzt werden.
  • Es wäre gut, wenn der Mauszeiger anzeigt, dass die Ballons anklickbar sind, also können wir cursor: pointer auch für die <summary>-Elemente festlegen.
  • Ein letztes Detail ist das Setzen der Eigenschaft user-select auf none für die <summary>-Elemente, um zu verhindern, dass die Ballons – die einfach nur Emoji-Text sind – markiert werden. Dadurch wirken sie eher wie echte Objekte auf der Seite.
  • Und ja, wir haben 2024 und brauchen immer noch die präfixte Eigenschaft -webkit-user-select für den Safari-Support. Danke, Apple.

Hier ist der gesamte Code in einer .balloon-Klasse, die wir für die <button>- und <summary>-Elemente verwenden:

.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;
}

Ein Problem mit den Ballons ist, dass einige von ihnen absichtlich gar nichts tun. Das liegt daran, dass die Popover, die sie schließen sollen, gar nicht offen sind. Der Spieler könnte denken, er hätte diesen speziellen Ballon nicht richtig angeklickt/getippt oder das Spiel sei kaputt. Fügen wir also eine kleine Skalierung hinzu, während sich der Ballon im :active-Zustand des Klickens befindet:

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

Bonus: Da der cursor eine Hand mit ausgestrecktem Zeigefinger ist, sieht das Klicken auf einen Ballon fast so aus, als würde die Hand den Ballon mit dem Finger anstechen. 👉🎈💥

Die Art und Weise, wie wir die Ballons auf dem Bildschirm verteilen, ist ein weiterer wichtiger Punkt. Ohne JavaScript können wir sie nicht zufällig positionieren, also fällt das weg. Ich habe einiges ausprobiert, etwa eigene „Zufallszahlen“ als benutzerdefinierte Eigenschaften definiert, die als Multiplikatoren dienen, aber das Ergebnis fühlte sich nie wirklich „zufällig“ an, ohne dass sich Ballons überlagerten oder visuelle Muster entstanden.

Ich bin letztlich bei einer Methode gelandet, die Klassen verwendet, um die Ballons in verschiedenen Reihen und Spalten zu positionieren – nicht wie CSS Grid oder Multicolumns, sondern imaginäre Reihen und Spalten basierend auf physischen Insets. Es sieht ein bisschen wie ein Raster aus und ist weniger „Zufall“ als ich wollte, aber solange kein Ballon dieselben zwei Klassen hat, werden sie sich nicht überlappen.

Ich habe mich für ein 8×8-Raster entschieden, aber die erste „Reihe“ und „Spalte“ leer gelassen, damit die Ballons nicht direkt am linken und oberen Rand des Browsers kleben.

/* 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);
}

Dem Spieler gratulieren (oder auch nicht)

Die meisten Spielsteine sind an ihrem Platz, aber es wäre toll, eine Art Sieges-Tanz-Popover zu haben, um den Spielern zu gratulieren, wenn sie alle Ballons rechtzeitig platzen lassen.

Alles führt zurück zum <details open>-Element. Sobald dieses Element nicht mehr open ist, sollte das Spiel vorbei sein, wobei der letzte Schritt darin besteht, diesen finalen Ballon platzen zu lassen. Wenn wir diesem Element also eine ID geben, sagen wir #root, könnten wir eine Bedingung erstellen, um es mit display: none auszublenden, wenn es sich :not() im open-Zustand befindet.

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

Hier ist es fantastisch, dass wir den Pseudo-Selektor :has() haben. Wir können ihn nutzen, um das Elternelement von #root auszuwählen. Wenn #root geschlossen ist, können wir ein Kind dieses Elternelements auswählen – ein neues Element mit der ID #congrats –, um ein Pseudo-Popover mit der Glückwunschnachricht anzuzeigen. (Ja, die Ironie ist mir bewusst.)

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

Wenn wir das Spiel zu diesem Zeitpunkt spielen würden, könnten wir die Siegesnachricht erhalten, ohne alle Ballons platzen zu lassen. Wie gesagt: Manuelle Popover schließen sich nicht, es sei denn, der richtige Button wird geklickt – selbst wenn wir das übergeordnete <details>-Element schließen.

Gibt es in CSS einen Weg zu wissen, ob ein Popover noch offen ist? Ja, die Pseudo-Klasse :popover-open.

Die Pseudo-Klasse :popover-open wählt ein offenes Popover aus. Wir können sie in Kombination mit :has() verwenden, um zu verhindern, dass die Nachricht erscheint, solange noch ein Popover auf der Seite offen ist. So sieht es aus, wenn man diese Dinge wie eine and-Bedingung aneinanderkettet:

/* 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;
}

Jetzt wird dem Spieler erst gratuliert, wenn er auch wirklich… naja, gewonnen hat.

Umgekehrt: Wenn ein Spieler es nicht schafft, alle Ballons platzen zu lassen, bevor ein Timer abläuft, sollten wir ihn informieren, dass das Spiel vorbei ist. Da wir in CSS keine if()-Bedingung haben (jedenfalls noch nicht), lassen wir eine Animation eine Minute lang laufen, nach der diese Nachricht eingeblendet wird, um das Spiel zu beenden.

#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;
  }
}

Wir wollen aber nicht, dass die „Verloren“-Nachricht erscheint, wenn der Siegesbildschirm bereits da ist. Also schreiben wir einen Selektor, der verhindert, dass die #fail-Nachricht gleichzeitig mit der #congrats-Nachricht angezeigt wird.

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

Wir brauchen einen Spiel-Timer

Ein Spieler sollte wissen, wie viel Zeit noch bleibt. Wir können einen recht „einfachen“ Timer erstellen: Ein Element, das die volle Bildschirmbreite einnimmt (100vw), horizontal skaliert wird und zeitlich auf die obige Animation abgestimmt ist, die die #fail-Nachricht einblendet.

#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;
  }
}

Nur einen einzigen „Point of Failure“ zu haben, könnte das Spiel zu einfach machen. Versuchen wir also, ein zweites <details>-Element mit einer zweiten „Root“-ID, #root2, hinzuzufügen. Auch hier können wir :has verwenden, um zu prüfen, ob weder #root noch #root2 open sind, bevor die #congrats-Nachricht angezeigt wird.

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

Zusammenfassung

Das Einzige, was jetzt noch zu tun bleibt, ist das Spiel zu spielen!

Macht Spaß, oder? Sicherlich hätten wir ohne die selbst auferlegte Einschränkung, auf JavaScript zu verzichten, etwas Robusteres bauen können, und wir haben das Ganze auch nicht wirklich auf Barrierefreiheit geprüft. Aber eine API an ihre Grenzen zu bringen, macht doch sowohl Spaß als auch schlau, oder?


Mich würde interessieren: Welche anderen verrückten Ideen fallen Ihnen für die Nutzung von Popovern ein? Vielleicht haben Sie ein anderes Spiel im Sinn, einen schicken UI-Effekt oder einen cleveren Weg, Popover mit anderen neuen CSS-Features zu kombinieren, wie Anchor Positioning. Was auch immer es ist, bitte teilen Sie es!