Let menu entries appear as buttons with icon, colours and phone placement

The menu editor gets "Als Button darstellen" per entry: shape, appearance and
size from the button design, fill and ink with hover and palette, icon colour,
icon from the dialog, motion, icon-only and where the button sits on the phone
(in the opened menu, beside the burger, or there as an icon only). Colours go
into the page stylesheet under a stable per-button id because the CSP forbids
inline styles. ADR 110.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5.5 committed 2026-10-07 22:36:12 +02:00
1 parent 48ae2443cd
commit 13f6759df8
22 files changed
+623 -14

No files matched your search

+136
View File
@@ -0,0 +1,136 @@
<?php
namespace App\Content;
use App\Domain\InvalidMenu;
/**
* Ein Menüeintrag, der als Button erscheint (ADR 110).
*
* Die Gestalt kommt aus demselben Wortschatz wie der Button-Baustein: Form, Darstellung und Größe
* aus ButtonDesign, das Symbol aus dem Symbolsatz. Fehlt ein Wert, gilt der Website-Standard unter
* Design → Buttons. Eigene Farben brauchen eine Kennung, weil die Sicherheitsrichtlinie keine
* Inline-Stile erlaubt: sie werden als Regel für `.menu-button-<id>` ins Stylesheet der Seite
* geschrieben.
*
* Gespeichert am Eintrag als `button`:
* {id, shape?, appearance?, size?, fill?, ink?, hoverFill?, hoverInk?, iconColor?, mobile, iconMotion?, iconOnly?}
*/
final class MenuButton
{
/** Wo der Button auf dem Telefon steht, wenn das Menü zum Menü-Knopf zusammenklappt. */
public const MOBILE = ['menu' => 'Im aufgeklappten Menü', 'bar' => 'Neben dem Menü-Knopf', 'bar-icon' => 'Neben dem Menü-Knopf, nur als Symbol'];
public const MOTION = ['none', 'slide', 'reveal'];
/** Formularfeld => gespeicherter Schlüssel. */
public const COLORS = ['buttonFill' => 'fill', 'buttonInk' => 'ink', 'buttonHoverFill' => 'hoverFill', 'buttonHoverInk' => 'hoverInk', 'buttonIconColor' => 'iconColor'];
private const DESIGN = ['buttonShape' => 'shape', 'buttonAppearance' => 'appearance', 'buttonSize' => 'size'];
/**
* Aus den flachen Formularfeldern einer Zeile. Ohne Häkchen „Als Button“ kein Button.
*/
public static function fromForm(array $row): ?array
{
if ('1' !== (string) ($row['button'] ?? '')) {
return null;
}
$id = (string) ($row['buttonId'] ?? '');
$button = ['id' => 1 === preg_match('/^m[a-f0-9]{10}$/D', $id) ? $id : 'm'.bin2hex(random_bytes(5))];
foreach ([...self::DESIGN, ...self::COLORS] as $field => $key) {
$value = trim((string) ($row[$field] ?? ''));
if ('' !== $value) {
$button[$key] = $value;
}
}
$button['mobile'] = (string) ($row['buttonMobile'] ?? 'menu');
$motion = (string) ($row['iconMotion'] ?? 'none');
if ('none' !== $motion) {
$button['iconMotion'] = $motion;
}
if ('1' === (string) ($row['iconOnly'] ?? '')) {
$button['iconOnly'] = true;
}
return $button;
}
public static function validate(mixed $button, array $item): void
{
if (!is_array($button) || 1 !== preg_match('/^m[a-f0-9]{10}$/D', (string) ($button['id'] ?? ''))) {
throw new InvalidMenu('Ein Menü-Button braucht eine Kennung.');
}
$known = ['id', 'mobile', 'iconMotion', 'iconOnly', ...array_values(self::DESIGN), ...array_values(self::COLORS)];
if ([] !== array_diff(array_keys($button), $known)) {
throw new InvalidMenu('Unbekannte Einstellung am Menü-Button.');
}
foreach (self::DESIGN as $key) {
if (isset($button[$key]) && !isset(ButtonDesign::OPTIONS[$key][$button[$key]])) {
throw new InvalidMenu('Unbekannte Form, Darstellung oder Größe am Menü-Button.');
}
}
foreach (self::COLORS as $key) {
if (isset($button[$key]) && (!is_string($button[$key]) || 1 !== preg_match(StyleSchema::COLOR, $button[$key]))) {
throw new InvalidMenu('Eine Farbe am Menü-Button ist ein Hexwert wie #b71c22 oder eine Farbe der Palette.');
}
}
if (!isset(self::MOBILE[$button['mobile'] ?? ''])) {
throw new InvalidMenu('Unbekannte Anzeige auf dem Telefon.');
}
// Auf dem Telefon nur als Symbol: ohne Symbol stünde dort ein leerer Knopf.
if ('bar-icon' === $button['mobile'] && '' === ($item['icon'] ?? '')) {
throw new InvalidMenu('Nur als Symbol neben dem Menü-Knopf braucht ein Symbol.');
}
if (isset($button['iconMotion']) && !in_array($button['iconMotion'], self::MOTION, true)) {
throw new InvalidMenu('Unbekannte Bewegung des Symbols.');
}
if (isset($button['iconOnly']) && (true !== $button['iconOnly'] || '' === ($item['icon'] ?? ''))) {
throw new InvalidMenu('Ein Menü-Button nur aus Symbol braucht ein Symbol.');
}
// Ein Knopf öffnet kein Untermenü: mit Unterpunkten wäre er Knopf und Aufklapper zugleich.
if ([] !== ($item['children'] ?? [])) {
throw new InvalidMenu(sprintf('„%s“ hat Unterpunkte und kann deshalb kein Button sein.', $item['label'] ?? ''));
}
}
/**
* Die Farbregeln aller Menü-Buttons. Gesetzt werden die Variablen, aus denen die Button-Stile
* ihre Flächen berechnen, plus die abgeleiteten Werte der gewählten oder der Standard-
* Darstellung - sonst rechnete ein Button ohne eigene Darstellung weiter mit den Farben vom
* Seitenanfang.
*
* @param array<string, array{items: array}> $menus
*/
public static function css(array $menus, array $themeButtons): string
{
$color = static fn (string $v): string => str_starts_with($v, '@') ? 'var(--k-'.substr($v, 1).')' : $v;
$css = '';
$walk = function (array $items) use (&$walk, &$css, $color, $themeButtons): void {
foreach ($items as $item) {
$walk($item['children'] ?? []);
$button = $item['button'] ?? null;
if (!is_array($button) || !isset($button['id'])) {
continue;
}
$vars = [];
foreach (['fill' => '--kb-fill', 'ink' => '--kb-ink', 'hoverFill' => '--kb-hover-fill', 'hoverInk' => '--kb-hover-ink'] as $key => $var) {
if (isset($button[$key])) {
$vars[] = $var.':'.$color($button[$key]);
}
}
if ([] !== $vars) {
foreach (ButtonDesign::appearance($button['appearance'] ?? $themeButtons['appearance'] ?? 'solid') as $key => $value) {
$vars[] = '--kb-'.$key.':'.$value;
}
$css .= sprintf('.menu-button-%s{%s}', $button['id'], implode(';', $vars));
}
if (isset($button['iconColor'])) {
$css .= sprintf('.menu-button-%s .button-icon{color:%s}', $button['id'], $color($button['iconColor']));
}
}
};
foreach ($menus as $menu) {
$walk($menu['items'] ?? []);
}
return $css;
}
}