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:
1 parent
48ae2443cd
commit
13f6759df8
22 files changed
+623
-14
No files matched your search
@@ -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');
|
||||
|
||||
@@ -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')));
|
||||
|
||||
Reference in new issue
Block a user