Let a button carry a chosen icon before or after its text, and pick icons from tiles
Schema v25: a button takes an optional icon (any name from the shared set or "none") and an icon position (start or end). Without a choice it keeps the diagonal arrow behind the text, still with the button-arrow class, so stored pages and their own CSS look exactly as before. Thirteen everyday icons join the set (arrow down, chevron, calendar, message, send, download, document, play, search, heart, info, plus, person). Button, icon and icon card choose from a tile picker with a drawing per icon, grouped, keyboard navigable, instead of a select with English keys. icons.js holds the paths for the editor; a test keeps them equal to the Twig drawings and the validator list. ADR 108. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e1753fa7e4
commit
88737a95a9
17 files changed
+463
-15
No files matched your search
+52
-1
@@ -273,7 +273,58 @@ final class CmsTest extends TestCase
|
||||
public function testTheButtonArrowTakesTheColourOfTheButtonText(): void
|
||||
{
|
||||
// Der Pfeil ist ein Symbol (block-icon) und bekam die Akzentfarbe: grün auf einem roten Knopf.
|
||||
self::assertStringContainsString('.button-arrow .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
||||
self::assertStringContainsString('.button-icon .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Schema v25: Umut wollte Buttons mit Symbol und den festen Pfeil ändern können. Ohne Wahl
|
||||
* bleibt alles wie bisher - der Pfeil mit der Klasse button-arrow, auf die eigenes CSS älterer
|
||||
* Websites zielt (Hüfner blendet ihn so aus).
|
||||
*/
|
||||
public function testAButtonCarriesTheChosenIconBeforeOrAfterItsTextOrNone(): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$button = $document['blocks'][0]['children'][3];
|
||||
self::assertSame('button', $button['type']);
|
||||
$id = $this->pages->saveDraft('page-1', $document, 0);
|
||||
$this->pages->publish('page-1', $id, 1);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*>[^<]+ <span class="button-icon button-arrow" aria-hidden="true"><svg class="block-icon"[^>]*><path d="M6\.5 17\.5/', $html, 'Ohne Wahl: der schräge Pfeil hinter dem Text.');
|
||||
|
||||
$document['schemaVersion'] = 25;
|
||||
$document['blocks'][0]['children'][3]['props'] += ['icon' => 'phone', 'iconPosition' => 'start'];
|
||||
$id = $this->pages->saveDraft('page-1', $document, 2);
|
||||
$this->pages->publish('page-1', $id, 3);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*><span class="button-icon" aria-hidden="true"><svg class="block-icon"[^>]*><path d="M6 3h4l2 5[^"]*"\/><\/svg>\s*<\/span> '.preg_quote($button['props']['text'], '/').'<\/a>/', $html, 'Telefon vor dem Text, ohne die Pfeil-Klasse.');
|
||||
|
||||
$document['blocks'][0]['children'][3]['props']['icon'] = 'none';
|
||||
$id = $this->pages->saveDraft('page-1', $document, 4);
|
||||
$this->pages->publish('page-1', $id, 5);
|
||||
$html = (string) $this->request('/')->getContent();
|
||||
self::assertMatchesRegularExpression('/<a id="'.$button['id'].'"[^>]*>'.preg_quote($button['props']['text'], '/').'<\/a>/', $html, 'Kein Symbol: nur der Text.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<string, array{string, mixed}>
|
||||
*/
|
||||
public static function rejectedButtonIcons(): iterable
|
||||
{
|
||||
yield 'Symbol vor v25' => ['icon', 'phone', 24];
|
||||
yield 'unbekanntes Symbol' => ['icon', 'whatsapp', 25];
|
||||
yield 'Symbol ist kein Text' => ['icon', ['phone'], 25];
|
||||
yield 'Stellung vor v25' => ['iconPosition', 'start', 24];
|
||||
yield 'unbekannte Stellung' => ['iconPosition', 'middle', 25];
|
||||
}
|
||||
|
||||
#[DataProvider('rejectedButtonIcons')]
|
||||
public function testAButtonRejectsUnknownIconsAndOlderSchemas(string $key, mixed $value, int $version): void
|
||||
{
|
||||
$document = DemoContent::pages()['home'];
|
||||
$document['schemaVersion'] = $version;
|
||||
$document['blocks'][0]['children'][3]['props'][$key] = $value;
|
||||
$this->expectException(InvalidDocument::class);
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
#[DataProvider('invalidDocuments')]
|
||||
|
||||
@@ -656,4 +656,9 @@ test('a loaded document is raised to the schema its blocks and properties need',
|
||||
assert.equal(repairSchema(plain).schemaVersion, 5);
|
||||
const faq = { schemaVersion: 10, title: 'F', description: '', blocks: [{ id: 'a', type: 'accordion', props: { faq: true }, children: [] }] };
|
||||
assert.equal(repairSchema(faq).schemaVersion, 23);
|
||||
// Ein Button mit gewähltem Symbol braucht v25; ohne Symbol bleibt er bei seiner Fassung.
|
||||
const symbol = { schemaVersion: 24, title: 'S', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Anrufen', href: 'tel:+49', icon: 'phone' } }] };
|
||||
assert.equal(repairSchema(symbol).schemaVersion, 25);
|
||||
const pfeil = { schemaVersion: 24, title: 'P', description: '', blocks: [{ id: 'b', type: 'button', props: { text: 'Mehr', href: '/' } }] };
|
||||
assert.equal(repairSchema(pfeil).schemaVersion, 24);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { ICON_PATHS, ICON_LABELS, BUTTON_ICON_GROUPS, ALL_ICON_GROUPS } from '../public/assets/icons.js';
|
||||
|
||||
const read = path => readFileSync(new URL(path, import.meta.url), 'utf8');
|
||||
|
||||
test('the editor draws the same icons the website renders and the server allows', () => {
|
||||
// Drei Listen, ein Satz: die Zeichnungen der Website (Twig), die Vorschau im Editor und die
|
||||
// erlaubten Namen im Validator. Weicht eine ab, zeigt die Auswahl ein anderes Symbol als die
|
||||
// Seite - oder bietet eins an, das der Server beim Speichern ablehnt.
|
||||
const twig = read('../templates/blocks/icon.html.twig');
|
||||
const website = Object.fromEntries([...twig.matchAll(/^\s{4}'?([a-z-]+)'?: '([^']+)',?$/gm)].map(([, name, path]) => [name, path]));
|
||||
assert.deepEqual(ICON_PATHS, website);
|
||||
const validator = read('../src/Content/DocumentValidator.php');
|
||||
const allowed = [...validator.match(/public const ICONS = \[([^\]]+)\]/)[1].matchAll(/'([a-z-]+)'/g)].map(([, name]) => name);
|
||||
assert.deepEqual(Object.keys(ICON_PATHS).sort(), [...allowed].sort());
|
||||
});
|
||||
|
||||
test('every icon has a German name and the pickers offer each icon exactly once', () => {
|
||||
assert.deepEqual(Object.keys(ICON_LABELS).sort(), Object.keys(ICON_PATHS).sort());
|
||||
const button = BUTTON_ICON_GROUPS.flatMap(([, names]) => names);
|
||||
assert.equal(new Set(button).size, button.length, 'Ein Symbol steht doppelt in der Button-Auswahl.');
|
||||
for (const name of button) assert.ok(ICON_PATHS[name], `${name} hat keine Zeichnung.`);
|
||||
// Der bisherige Pfeil steht vorn: er ist das, was ein Button ohne eigene Wahl trägt.
|
||||
assert.equal(button[0], 'arrow-up-right');
|
||||
const all = ALL_ICON_GROUPS.flatMap(([, names]) => names);
|
||||
assert.deepEqual([...all].sort(), Object.keys(ICON_PATHS).sort(), 'Symbol und Symbolkarte bieten jedes Symbol genau einmal an.');
|
||||
});
|
||||
Reference in new issue
Block a user