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');