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

+74
View File
@@ -737,6 +737,80 @@ final class CmsTest extends TestCase
self::assertStringNotContainsString('<a href="/" aria-current="page">', $other);
}
/**
* ADR 110: ein Menüeintrag als Button - dieselben Klassen wie der Button-Baustein, eigene
* Farben als Regel im Stylesheet der Seite, und auf dem Telefon wahlweise neben dem Menü-Knopf.
*/
public function testAMenuEntryRendersAsAButtonWithItsOwnColoursAndStaysBesideTheBurger(): void
{
$this->menus->save('header', 'Hauptnavigation', [
['label' => 'Start', 'type' => 'page', 'pageId' => 'page-1', 'anchor' => '', 'children' => []],
['label' => 'Angebot anfragen', 'type' => 'url', 'url' => '#kontakt', 'icon' => 'arrow-right', 'iconPosition' => 'after', 'children' => [],
'button' => ['id' => 'mabcdef0123', 'shape' => 'pill', 'appearance' => 'solid', 'fill' => '#b71c22', 'ink' => '#ffffff', 'hoverFill' => '@ink', 'iconColor' => '#ffd700', 'mobile' => 'bar', 'iconMotion' => 'slide']],
['label' => 'Anrufen', 'type' => 'url', 'url' => 'tel:+4972296992826', 'icon' => 'phone', 'children' => [],
'button' => ['id' => 'm0123456789', 'appearance' => 'outline', 'mobile' => 'menu', 'iconOnly' => true]],
['label' => 'Hotline', 'type' => 'url', 'url' => 'tel:+497229', 'icon' => 'phone', 'children' => [],
'button' => ['id' => 'm1111111111', 'mobile' => 'bar-icon']],
], 0);
$document = DemoContent::starter('Mit Menü');
$document['schemaVersion'] = 22;
$document['blocks'][0]['children'][] = ['id' => 'nav', 'type' => 'menu', 'props' => ['slot' => 'header', 'layout' => 'horizontal', 'collapse' => 'tablet']];
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 0), 1);
$response = $this->request('/');
$html = (string) $response->getContent();
$crawler = new \Symfony\Component\DomCrawler\Crawler($html);
$offer = $crawler->filter('#nav > .menu-list a.menu-button-mabcdef0123');
self::assertSame('button cms-button menu-button menu-button-mabcdef0123 b-desktop-shape-pill b-desktop-appearance-solid icon-motion-slide', $offer->attr('class'));
self::assertSame('#kontakt', $offer->attr('href'));
self::assertSame('Angebot anfragen', $offer->filter('.button-label')->text());
self::assertCount(1, $offer->filter('.button-icon svg'));
self::assertSame('menu-item-button menu-keep-visible', $offer->closest('li')->attr('class'));
// Beim Menü-Knopf steht er ein zweites Mal - sichtbar nur, solange das Menü zusammengeklappt ist.
self::assertCount(1, $crawler->filter('#nav > ul.menu-actions a.menu-button-mabcdef0123'));
self::assertCount(0, $crawler->filter('#nav > ul.menu-actions a.menu-button-m0123456789'));
// Nur Symbol: die Beschriftung bleibt als Name des Links.
$call = $crawler->filter('#nav > .menu-list a.menu-button-m0123456789');
self::assertStringContainsString('icon-start icon-only', (string) $call->attr('class'));
self::assertSame('Anrufen', $call->filter('.button-label')->text());
// Neben dem Menü-Knopf nur als Symbol, im Menü selbst mit Beschriftung.
self::assertStringContainsString('icon-only', (string) $crawler->filter('#nav > ul.menu-actions a.menu-button-m1111111111')->attr('class'));
self::assertStringNotContainsString('icon-only', (string) $crawler->filter('#nav > .menu-list a.menu-button-m1111111111')->attr('class'));
self::assertSame('Hotline', $crawler->filter('#nav > ul.menu-actions a.menu-button-m1111111111 .button-label')->text());
// Gewöhnliche Einträge bleiben gewöhnliche Links.
self::assertCount(1, $crawler->filter('#nav > .menu-list a[href="/"] .nav-label'));
self::assertStringContainsString('.menu-button-mabcdef0123{--kb-fill:#b71c22;--kb-ink:#ffffff;--kb-hover-fill:var(--k-ink);--kb-bg:var(--kb-fill)', $html);
self::assertStringContainsString('.menu-button-mabcdef0123 .button-icon{color:#ffd700}', $html);
self::assertStringNotContainsString('.menu-button-m0123456789{', $html, 'Ohne eigene Farben keine Regel.');
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
self::assertStringContainsString('.menu-collapse-tablet>.menu-actions{display:flex}', $css);
self::assertStringContainsString('.menu-collapse-tablet .menu-burger-body .menu-keep-visible{display:none}', $css);
}
/** @return iterable<string, array{array}> */
public static function rejectedMenuButtons(): iterable
{
$entry = ['label' => 'Angebot', 'type' => 'url', 'url' => '/kontakt', 'children' => []];
$button = ['id' => 'mabcdef0123', 'mobile' => 'menu'];
yield 'ohne Kennung' => [[...$entry, 'button' => ['mobile' => 'menu']]];
yield 'unbekannte Form' => [[...$entry, 'button' => [...$button, 'shape' => 'round']]];
yield 'Farbe als Wort' => [[...$entry, 'button' => [...$button, 'fill' => 'red']]];
yield 'CSS in der Farbe' => [[...$entry, 'button' => [...$button, 'ink' => '#fff;}body{display:none']]];
yield 'unbekannte Anzeige auf dem Telefon' => [[...$entry, 'button' => [...$button, 'mobile' => 'oben']]];
yield 'unbekannte Einstellung' => [[...$entry, 'button' => [...$button, 'style' => 'x']]];
yield 'neben dem Menü-Knopf nur als Symbol, ohne Symbol' => [[...$entry, 'button' => [...$button, 'mobile' => 'bar-icon']]];
yield 'nur Symbol ohne Symbol' => [[...$entry, 'button' => [...$button, 'iconOnly' => true]]];
yield 'Button mit Unterpunkten' => [[...$entry, 'button' => $button, 'children' => [['label' => 'Unter', 'type' => 'url', 'url' => '/a', 'children' => []]]]];
}
#[DataProvider('rejectedMenuButtons')]
public function testAMenuButtonRejectsUnknownOrUnsafeSettings(array $entry): void
{
$this->expectException(InvalidMenu::class);
$this->menus->save('header', 'Hauptnavigation', [$entry], 0);
}
public function testMenuSurvivesDeletedAndUnpublishedPagesWithoutLosingOtherEntries(): void
{
$doomed = $this->publishedPage('temporaer', 'Temporaer');
+32
View File
@@ -405,6 +405,38 @@ final class EditorSecurityTest extends WebTestCase
self::assertSame(['/', '/#kontakt', 'https://hausklar.app'], $public->extract(['href']));
}
/**
* ADR 110: Ein Menüeintrag wird über das Formular zum Button - mit Gestalt, Farben, Symbol,
* Bewegung und Anzeige auf dem Telefon. Die Kennung für die Farbregel entsteht beim ersten
* Speichern und bleibt danach gleich; leere Felder heißen Website-Standard und fehlen.
*/
public function testMenuFormTurnsAnEntryIntoAButtonAndKeepsItsId(): void
{
$client = $this->login('publisher');
$crawler = $client->request('GET', '/admin/menus');
$token = $crawler->filter('.menu-form input[name="_csrf_token"]')->first()->attr('value');
$row = ['position' => 10, 'level' => 1, 'label' => 'Angebot anfragen', 'type' => 'url', 'url' => '#kontakt',
'icon' => 'arrow-right', 'iconPosition' => 'after', 'button' => '1', 'buttonId' => '', 'buttonShape' => 'pill', 'buttonAppearance' => '',
'buttonSize' => '', 'buttonFill' => '#b71c22', 'buttonInk' => '', 'buttonHoverFill' => '@ink', 'buttonHoverInk' => '', 'buttonIconColor' => '',
'iconMotion' => 'slide', 'buttonMobile' => 'bar'];
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 0, 'items' => [$row]]);
self::assertResponseRedirects('/admin/menus');
$button = json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button'];
self::assertMatchesRegularExpression('/^m[a-f0-9]{10}$/', $button['id']);
self::assertSame(['shape' => 'pill', 'fill' => '#b71c22', 'hoverFill' => '@ink', 'mobile' => 'bar', 'iconMotion' => 'slide'], array_diff_key($button, ['id' => true]));
// Die Verwaltung zeigt die Werte wieder an, und erneutes Speichern behält die Kennung.
$crawler = $client->request('GET', '/admin/menus');
self::assertSame($button['id'], $crawler->filter('input[data-key="buttonId"]')->attr('value'));
self::assertSame('pill', $crawler->filter('select[data-key="buttonShape"] option[selected]')->attr('value'));
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 1, 'items' => [[...$row, 'buttonId' => $button['id']]]]);
self::assertSame($button['id'], json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]['button']['id']);
// Ohne Häkchen ist es wieder ein Link.
$client->request('POST', '/admin/menus/header', ['_csrf_token' => $token, 'name' => 'Hauptnavigation', 'expectedVersion' => 2, 'items' => [array_diff_key($row, ['button' => true])]]);
self::assertArrayNotHasKey('button', json_decode($this->db->fetchOne('SELECT items FROM menu WHERE slot = ?', ['header']), true)[0]);
}
public function testMenuWritesNeedAPublisherRoleACsrfTokenAndTheCurrentVersion(): void
{
$menus = new MenuRepository($this->db, new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));