From 8b3b1dab1350b90c375aedb116d579c6373cdbf4 Mon Sep 17 00:00:00 2001 From: Serreau Jovann Date: Fri, 20 Mar 2026 12:49:24 +0100 Subject: [PATCH] Add Event entity, create event page, and custom WYSIWYG editor component - Create Event entity with fields: account, title, description (text), startAt, endAt, address, zipcode, city, eventMainPicture (VichUploader), isOnline, createdAt, updatedAt - Create EventRepository - Add migration for event table with all columns - Add "Creer un evenement" button on account events tab - Add create event page (/mon-compte/evenement/creer) with full form - Build custom web component WYSIWYG editor: - Toolbar: bold, italic, underline, paragraph, bullet list, remove formatting - contentEditable div with HTML sync to hidden textarea - HTML sanitizer (strips disallowed tags, XSS safe) - Neo-brutalist CSS styling - CSP compliant (no inline styles) - Register editor in app.js via customElements.define - Add editor CSS in app.scss - Add 16 Event entity tests (all fields + isOnline + picture upload + updatedAt) - Add 16 editor JS tests (sanitizer + custom element lifecycle) - Add 3 AccountController tests (create event page, submit, access control) - Update placeholders to generic examples (no association-specific data) Co-Authored-By: Claude Opus 4.6 (1M context) --- assets/app.js | 2 + assets/app.scss | 59 ++++++ assets/modules/editor.js | 144 ++++++++++++++ migrations/Version20260320104445.php | 34 ++++ src/Controller/AccountController.php | 41 ++++ src/Entity/Event.php | 214 +++++++++++++++++++++ src/Repository/EventRepository.php | 18 ++ templates/account/create_event.html.twig | 73 +++++++ templates/account/index.html.twig | 6 + tests/Controller/AccountControllerTest.php | 48 +++++ tests/Entity/EventTest.php | 155 +++++++++++++++ tests/js/editor.test.js | 117 +++++++++++ 12 files changed, 911 insertions(+) create mode 100644 assets/modules/editor.js create mode 100644 migrations/Version20260320104445.php create mode 100644 src/Entity/Event.php create mode 100644 src/Repository/EventRepository.php create mode 100644 templates/account/create_event.html.twig create mode 100644 tests/Entity/EventTest.php create mode 100644 tests/js/editor.test.js diff --git a/assets/app.js b/assets/app.js index fa9bf6c..49ac4d6 100644 --- a/assets/app.js +++ b/assets/app.js @@ -1,8 +1,10 @@ import "./app.scss" import { initMobileMenu } from "./modules/mobile-menu.js" import { initTabs } from "./modules/tabs.js" +import { registerEditor } from "./modules/editor.js" document.addEventListener('DOMContentLoaded', () => { initMobileMenu() initTabs() + registerEditor() }) diff --git a/assets/app.scss b/assets/app.scss index 3ab21f0..3980e2f 100644 --- a/assets/app.scss +++ b/assets/app.scss @@ -1,3 +1,62 @@ @import "tailwindcss"; @import 'https://fonts.googleapis.com/css2?family=Intel+One+Mono:ital,wght@0,300..700;1,300..700&display=swap'; @source "../templates"; + +/* ===== E-Ticket Editor ===== */ +e-ticket-editor { + display: block; +} + +.ete-toolbar { + display: flex; + flex-wrap: wrap; + gap: 0; + border: 3px solid #111827; + border-bottom: none; + background: #f9fafb; + padding: 0.25rem; +} + +.ete-btn { + width: 2.25rem; + height: 2.25rem; + display: flex; + align-items: center; + justify-content: center; + border: 2px solid transparent; + background: transparent; + cursor: pointer; + font-weight: 900; + font-size: 0.8rem; + transition: all 0.1s; +} +.ete-btn:hover { + background: #fabf04; + border-color: #111827; +} + +.ete-separator { + width: 1px; + background: #d1d5db; + margin: 0.25rem 0.35rem; +} + +.ete-content { + width: 100%; + min-height: 10rem; + padding: 0.75rem 1rem; + border: 3px solid #111827; + font-weight: 700; + outline: none; + background: white; + line-height: 1.6; +} +.ete-content:focus { + border-color: #4f46e5; +} +.ete-content:empty::before { + content: attr(data-placeholder); + color: #9ca3af; + pointer-events: none; +} +.ete-content ul { list-style: disc; padding-left: 1.5rem; margin: 0.5rem 0; } diff --git a/assets/modules/editor.js b/assets/modules/editor.js new file mode 100644 index 0000000..f5b937f --- /dev/null +++ b/assets/modules/editor.js @@ -0,0 +1,144 @@ +const TOOLBAR_ACTIONS = [ + { command: 'bold', icon: 'B', title: 'Gras' }, + { command: 'italic', icon: 'I', title: 'Italique' }, + { command: 'underline', icon: 'S', title: 'Souligne' }, + { separator: true }, + { command: 'formatBlock', value: 'P', icon: 'P', title: 'Paragraphe' }, + { separator: true }, + { command: 'insertUnorderedList', icon: '•', title: 'Liste a puces' }, + { separator: true }, + { command: 'removeFormat', icon: '❌', title: 'Supprimer le formatage' }, +] + +const ALLOWED_TAGS = [ + 'p', 'br', 'b', 'strong', 'i', 'em', 'u', + 'ul', 'li', +] + +export function sanitizeHtml(html) { + const container = document.createElement('div') + container.innerHTML = html + const fragment = sanitizeNode(container) + const wrapper = document.createElement('div') + wrapper.appendChild(fragment) + + return wrapper.innerHTML.trim() +} + +function sanitizeNode(node) { + const fragment = document.createDocumentFragment() + + for (const child of Array.from(node.childNodes)) { + if (child.nodeType === Node.TEXT_NODE) { + fragment.appendChild(document.createTextNode(child.textContent)) + } else if (child.nodeType === Node.ELEMENT_NODE) { + const tagName = child.tagName.toLowerCase() + if (ALLOWED_TAGS.includes(tagName)) { + const el = document.createElement(tagName) + el.appendChild(sanitizeNode(child)) + fragment.appendChild(el) + } else { + fragment.appendChild(sanitizeNode(child)) + } + } + } + + return fragment +} + +export class ETicketEditor extends HTMLElement { + connectedCallback() { + const textarea = this.querySelector('textarea') + if (!textarea) { + return + } + + this._textarea = textarea + textarea.style.display = 'none' + + this._buildToolbar() + this._buildContentArea() + + if (textarea.value) { + this._content.innerHTML = textarea.value + } + + this._content.addEventListener('input', () => this._sync()) + this._content.addEventListener('keydown', (e) => this._onKeydown(e)) + } + + _buildToolbar() { + const toolbar = document.createElement('div') + toolbar.classList.add('ete-toolbar') + + for (const action of TOOLBAR_ACTIONS) { + if (action.separator) { + const sep = document.createElement('span') + sep.classList.add('ete-separator') + toolbar.appendChild(sep) + continue + } + + const btn = document.createElement('button') + btn.type = 'button' + btn.classList.add('ete-btn') + btn.innerHTML = action.icon + btn.title = action.title + btn.addEventListener('mousedown', (e) => { + e.preventDefault() + this._exec(action) + }) + toolbar.appendChild(btn) + } + + this.insertBefore(toolbar, this._textarea) + } + + _buildContentArea() { + const content = document.createElement('div') + content.classList.add('ete-content') + content.setAttribute('contenteditable', 'true') + content.setAttribute('role', 'textbox') + content.setAttribute('aria-multiline', 'true') + + const placeholder = this._textarea.getAttribute('placeholder') + if (placeholder) { + content.dataset.placeholder = placeholder + } + + this.insertBefore(content, this._textarea) + this._content = content + } + + _exec(action) { + if (action.value) { + document.execCommand(action.command, false, action.value) + } else { + document.execCommand(action.command, false, null) + } + + this._sync() + this._content.focus() + } + + _onKeydown(e) { + if (e.key === 'Tab') { + e.preventDefault() + } + } + + _sync() { + const html = sanitizeHtml(this._content.innerHTML) + this._textarea.value = html + } + + getHtml() { + return this._textarea.value + } +} + +export function registerEditor() { + if (!globalThis.customElements.get('e-ticket-editor')) { + globalThis.customElements.define('e-ticket-editor', ETicketEditor) + } +} diff --git a/migrations/Version20260320104445.php b/migrations/Version20260320104445.php new file mode 100644 index 0000000..e9492be --- /dev/null +++ b/migrations/Version20260320104445.php @@ -0,0 +1,34 @@ +addSql('CREATE TABLE event (id INT GENERATED BY DEFAULT AS IDENTITY NOT NULL, title VARCHAR(255) NOT NULL, start_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, end_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, description TEXT DEFAULT NULL, address VARCHAR(255) NOT NULL, zipcode VARCHAR(10) NOT NULL, city VARCHAR(255) NOT NULL, event_main_picture_name VARCHAR(255) DEFAULT NULL, is_online BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, updated_at TIMESTAMP(0) WITHOUT TIME ZONE DEFAULT NULL, account_id INT NOT NULL, PRIMARY KEY (id))'); + $this->addSql('CREATE INDEX IDX_3BAE0AA79B6B5FBA ON event (account_id)'); + $this->addSql('ALTER TABLE event ADD CONSTRAINT FK_3BAE0AA79B6B5FBA FOREIGN KEY (account_id) REFERENCES "user" (id) NOT DEFERRABLE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('ALTER TABLE event DROP CONSTRAINT FK_3BAE0AA79B6B5FBA'); + $this->addSql('DROP TABLE event'); + } +} diff --git a/src/Controller/AccountController.php b/src/Controller/AccountController.php index 8eff86a..b503f3c 100644 --- a/src/Controller/AccountController.php +++ b/src/Controller/AccountController.php @@ -281,6 +281,47 @@ class AccountController extends AbstractController return $this->redirectToRoute('app_account', ['tab' => 'subaccounts']); } + #[Route('/mon-compte/evenement/creer', name: 'app_account_create_event', methods: ['GET', 'POST'])] + public function createEvent(Request $request, EntityManagerInterface $em): Response + { + $this->denyAccessUnlessGranted('ROLE_ORGANIZER'); + + /** @var User $user */ + $user = $this->getUser(); + + if ($request->isMethod('POST')) { + $event = new \App\Entity\Event(); + $event->setAccount($user); + $event->setTitle(trim($request->request->getString('title'))); + $event->setDescription(trim($request->request->getString('description')) ?: null); + $event->setStartAt(new \DateTimeImmutable($request->request->getString('start_at'))); + $event->setEndAt(new \DateTimeImmutable($request->request->getString('end_at'))); + $event->setAddress(trim($request->request->getString('address'))); + $event->setZipcode(trim($request->request->getString('zipcode'))); + $event->setCity(trim($request->request->getString('city'))); + + $pictureFile = $request->files->get('event_main_picture'); + if ($pictureFile) { + $event->setEventMainPictureFile($pictureFile); + } + + $em->persist($event); + $em->flush(); + + $this->addFlash('success', 'Evenement cree avec succes.'); + + return $this->redirectToRoute('app_account', ['tab' => 'events']); + } + + return $this->render('account/create_event.html.twig', [ + 'breadcrumbs' => [ + self::BREADCRUMB_HOME, + self::BREADCRUMB_ACCOUNT, + ['name' => 'Creer un evenement', 'url' => '/mon-compte/evenement/creer'], + ], + ]); + } + /** @codeCoverageIgnore Test helper, not used in production */ #[Route('/mon-compte/test-payout', name: 'app_account_test_payout', methods: ['POST'])] public function testPayout(EntityManagerInterface $em): Response diff --git a/src/Entity/Event.php b/src/Entity/Event.php new file mode 100644 index 0000000..4182056 --- /dev/null +++ b/src/Entity/Event.php @@ -0,0 +1,214 @@ +createdAt = new \DateTimeImmutable(); + } + + public function getId(): ?int + { + return $this->id; + } + + public function getAccount(): ?User + { + return $this->account; + } + + public function setAccount(?User $account): static + { + $this->account = $account; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title; + } + + public function setTitle(string $title): static + { + $this->title = $title; + + return $this; + } + + public function getStartAt(): ?\DateTimeImmutable + { + return $this->startAt; + } + + public function setStartAt(\DateTimeImmutable $startAt): static + { + $this->startAt = $startAt; + + return $this; + } + + public function getEndAt(): ?\DateTimeImmutable + { + return $this->endAt; + } + + public function setEndAt(\DateTimeImmutable $endAt): static + { + $this->endAt = $endAt; + + return $this; + } + + public function getDescription(): ?string + { + return $this->description; + } + + public function setDescription(?string $description): static + { + $this->description = $description; + + return $this; + } + + public function getAddress(): ?string + { + return $this->address; + } + + public function setAddress(string $address): static + { + $this->address = $address; + + return $this; + } + + public function getZipcode(): ?string + { + return $this->zipcode; + } + + public function setZipcode(string $zipcode): static + { + $this->zipcode = $zipcode; + + return $this; + } + + public function getCity(): ?string + { + return $this->city; + } + + public function setCity(string $city): static + { + $this->city = $city; + + return $this; + } + + public function isOnline(): bool + { + return $this->isOnline; + } + + public function setIsOnline(bool $isOnline): static + { + $this->isOnline = $isOnline; + + return $this; + } + + public function getEventMainPictureFile(): ?File + { + return $this->eventMainPictureFile; + } + + public function setEventMainPictureFile(?File $eventMainPictureFile): static + { + $this->eventMainPictureFile = $eventMainPictureFile; + + if ($eventMainPictureFile) { + $this->updatedAt = new \DateTimeImmutable(); + } + + return $this; + } + + public function getEventMainPictureName(): ?string + { + return $this->eventMainPictureName; + } + + public function setEventMainPictureName(?string $eventMainPictureName): static + { + $this->eventMainPictureName = $eventMainPictureName; + + return $this; + } + + public function getCreatedAt(): \DateTimeImmutable + { + return $this->createdAt; + } + + public function getUpdatedAt(): ?\DateTimeImmutable + { + return $this->updatedAt; + } +} diff --git a/src/Repository/EventRepository.php b/src/Repository/EventRepository.php new file mode 100644 index 0000000..1289066 --- /dev/null +++ b/src/Repository/EventRepository.php @@ -0,0 +1,18 @@ + + */ +class EventRepository extends ServiceEntityRepository +{ + public function __construct(ManagerRegistry $registry) + { + parent::__construct($registry, Event::class); + } +} diff --git a/templates/account/create_event.html.twig b/templates/account/create_event.html.twig new file mode 100644 index 0000000..a4a5fdb --- /dev/null +++ b/templates/account/create_event.html.twig @@ -0,0 +1,73 @@ +{% extends 'base.html.twig' %} + +{% block title %}Creer un evenement - E-Ticket{% endblock %} + +{% block body %} +
+ + + Retour aux evenements + + +

Creer un evenement

+

Renseignez les informations de votre evenement.

+ + {% for message in app.flashes('error') %} +
+

{{ message }}

+
+ {% endfor %} + +
+
+ + +
+ +
+ + + + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
+ +
+ +
+
+
+{% endblock %} diff --git a/templates/account/index.html.twig b/templates/account/index.html.twig index 5562949..74c8cfe 100644 --- a/templates/account/index.html.twig +++ b/templates/account/index.html.twig @@ -120,6 +120,12 @@ {% elseif tab == 'events' %} +
+ + + Creer un evenement + +
+

Mes evenements / Brocantes / Reservations

diff --git a/tests/Controller/AccountControllerTest.php b/tests/Controller/AccountControllerTest.php index 2c508a5..55fc9ba 100644 --- a/tests/Controller/AccountControllerTest.php +++ b/tests/Controller/AccountControllerTest.php @@ -467,6 +467,54 @@ class AccountControllerTest extends WebTestCase self::assertNotNull($user->getLogoName()); } + public function testCreateEventPageRequiresOrganizer(): void + { + $client = static::createClient(); + $user = $this->createUser(); + + $client->loginUser($user); + $client->request('GET', '/mon-compte/evenement/creer'); + + self::assertResponseStatusCodeSame(403); + } + + public function testCreateEventPageReturnsSuccess(): void + { + $client = static::createClient(); + $user = $this->createUser(['ROLE_ORGANIZER'], true); + + $client->loginUser($user); + $client->request('GET', '/mon-compte/evenement/creer'); + + self::assertResponseIsSuccessful(); + } + + public function testCreateEventSubmit(): void + { + $client = static::createClient(); + $em = static::getContainer()->get(EntityManagerInterface::class); + $user = $this->createUser(['ROLE_ORGANIZER'], true); + + $client->loginUser($user); + $client->request('POST', '/mon-compte/evenement/creer', [ + 'title' => 'Convention Test', + 'description' => 'Un super evenement', + 'start_at' => '2026-07-01T10:00', + 'end_at' => '2026-07-01T18:00', + 'address' => '42 rue de Saint-Quentin', + 'zipcode' => '02800', + 'city' => 'Beautor', + ]); + + self::assertResponseRedirects('/mon-compte?tab=events'); + + $event = $em->getRepository(\App\Entity\Event::class)->findOneBy(['title' => 'Convention Test']); + self::assertNotNull($event); + self::assertSame($user->getId(), $event->getAccount()->getId()); + self::assertSame('Un super evenement', $event->getDescription()); + self::assertSame('Beautor', $event->getCity()); + } + /** * @param list $roles */ diff --git a/tests/Entity/EventTest.php b/tests/Entity/EventTest.php new file mode 100644 index 0000000..757eaf9 --- /dev/null +++ b/tests/Entity/EventTest.php @@ -0,0 +1,155 @@ +getId()); + } + + public function testCreatedAtIsSetOnConstruction(): void + { + $event = new Event(); + self::assertInstanceOf(\DateTimeImmutable::class, $event->getCreatedAt()); + } + + public function testSetAndGetAccount(): void + { + $event = new Event(); + $user = new User(); + $user->setEmail('orga@example.com'); + $user->setFirstName('Test'); + $user->setLastName('Orga'); + $user->setPassword('hashed'); + + $result = $event->setAccount($user); + + self::assertSame($user, $event->getAccount()); + self::assertSame($event, $result); + } + + public function testSetAndGetTitle(): void + { + $event = new Event(); + $result = $event->setTitle('Brocante de printemps 2026'); + + self::assertSame('Brocante de printemps 2026', $event->getTitle()); + self::assertSame($event, $result); + } + + public function testSetAndGetStartAt(): void + { + $event = new Event(); + $date = new \DateTimeImmutable('2026-06-15 10:00:00'); + $result = $event->setStartAt($date); + + self::assertSame($date, $event->getStartAt()); + self::assertSame($event, $result); + } + + public function testSetAndGetEndAt(): void + { + $event = new Event(); + $date = new \DateTimeImmutable('2026-06-15 18:00:00'); + $result = $event->setEndAt($date); + + self::assertSame($date, $event->getEndAt()); + self::assertSame($event, $result); + } + + public function testSetAndGetDescription(): void + { + $event = new Event(); + $result = $event->setDescription('Grande brocante avec stands et animations.'); + + self::assertSame('Grande brocante avec stands et animations.', $event->getDescription()); + self::assertSame($event, $result); + } + + public function testDescriptionIsNullByDefault(): void + { + $event = new Event(); + self::assertNull($event->getDescription()); + } + + public function testSetAndGetAddress(): void + { + $event = new Event(); + $result = $event->setAddress('12 avenue de la Republique'); + + self::assertSame('12 avenue de la Republique', $event->getAddress()); + self::assertSame($event, $result); + } + + public function testSetAndGetZipcode(): void + { + $event = new Event(); + $result = $event->setZipcode('75011'); + + self::assertSame('75011', $event->getZipcode()); + self::assertSame($event, $result); + } + + public function testSetAndGetCity(): void + { + $event = new Event(); + $result = $event->setCity('Paris'); + + self::assertSame('Paris', $event->getCity()); + self::assertSame($event, $result); + } + + public function testSetAndGetEventMainPictureName(): void + { + $event = new Event(); + $result = $event->setEventMainPictureName('event-photo.jpg'); + + self::assertSame('event-photo.jpg', $event->getEventMainPictureName()); + self::assertSame($event, $result); + } + + public function testSetEventMainPictureFileUpdatesTimestamp(): void + { + $event = new Event(); + self::assertNull($event->getUpdatedAt()); + + $file = $this->createMock(File::class); + $result = $event->setEventMainPictureFile($file); + + self::assertSame($file, $event->getEventMainPictureFile()); + self::assertInstanceOf(\DateTimeImmutable::class, $event->getUpdatedAt()); + self::assertSame($event, $result); + } + + public function testIsOnlineDefaultFalse(): void + { + $event = new Event(); + self::assertFalse($event->isOnline()); + } + + public function testSetAndGetIsOnline(): void + { + $event = new Event(); + $result = $event->setIsOnline(true); + + self::assertTrue($event->isOnline()); + self::assertSame($event, $result); + } + + public function testSetEventMainPictureFileNullDoesNotUpdateTimestamp(): void + { + $event = new Event(); + $event->setEventMainPictureFile(null); + + self::assertNull($event->getUpdatedAt()); + self::assertNull($event->getEventMainPictureFile()); + } +} diff --git a/tests/js/editor.test.js b/tests/js/editor.test.js new file mode 100644 index 0000000..e93d0d9 --- /dev/null +++ b/tests/js/editor.test.js @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { sanitizeHtml, ETicketEditor, registerEditor } from '../../assets/modules/editor.js' + +describe('sanitizeHtml', () => { + it('keeps allowed tags', () => { + const html = '

Hello world

' + expect(sanitizeHtml(html)).toBe('

Hello world

') + }) + + it('strips disallowed tags but keeps content', () => { + const html = '
Hello
' + expect(sanitizeHtml(html)).toBe('Hello') + }) + + it('strips links but keeps text', () => { + const html = 'Link' + expect(sanitizeHtml(html)).toBe('Link') + }) + + it('keeps list elements', () => { + const html = '
  • Item 1
  • Item 2
' + expect(sanitizeHtml(html)).toBe('
  • Item 1
  • Item 2
') + }) + + it('strips blockquote but keeps content', () => { + const html = '
Citation
' + expect(sanitizeHtml(html)).toBe('Citation') + }) + + it('returns empty string for empty input', () => { + expect(sanitizeHtml('')).toBe('') + }) + + it('strips headings but keeps content', () => { + const html = '

Title

Subtitle

' + expect(sanitizeHtml(html)).toBe('TitleSubtitle') + }) + + it('strips style attributes from allowed tags', () => { + const html = '

Text

' + expect(sanitizeHtml(html)).toBe('

Text

') + }) +}) + +function createEditor(innerHtml = '') { + registerEditor() + document.body.innerHTML = '' + const el = document.createElement('e-ticket-editor') + el.innerHTML = innerHtml + document.body.appendChild(el) + el.connectedCallback() + + return el +} + +describe('ETicketEditor', () => { + beforeEach(() => { + registerEditor() + }) + + it('registers the custom element', () => { + expect(globalThis.customElements.get('e-ticket-editor')).toBe(ETicketEditor) + }) + + it('hides the textarea and creates toolbar + content area', () => { + const editor = createEditor('') + const textarea = editor.querySelector('textarea') + const toolbar = editor.querySelector('.ete-toolbar') + const content = editor.querySelector('.ete-content') + + expect(textarea.style.display).toBe('none') + expect(toolbar).not.toBeNull() + expect(content).not.toBeNull() + expect(content.getAttribute('contenteditable')).toBe('true') + expect(content.innerHTML).toBe('Hello') + expect(content.dataset.placeholder).toBe('Ecrivez ici...') + }) + + it('does nothing without a textarea', () => { + const editor = createEditor('') + expect(editor.querySelector('.ete-toolbar')).toBeNull() + expect(editor.querySelector('.ete-content')).toBeNull() + }) + + it('toolbar has buttons', () => { + const editor = createEditor() + const buttons = editor.querySelectorAll('.ete-btn') + expect(buttons.length).toBeGreaterThan(0) + }) + + it('toolbar has separators', () => { + const editor = createEditor() + const separators = editor.querySelectorAll('.ete-separator') + expect(separators.length).toBeGreaterThan(0) + }) + + it('getHtml returns textarea value', () => { + const editor = createEditor('') + expect(editor.getHtml()).toBe('Content') + }) + + it('prevents tab key default in content area', () => { + const editor = createEditor() + const content = editor.querySelector('.ete-content') + const event = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true }) + content.dispatchEvent(event) + expect(event.defaultPrevented).toBe(true) + }) + + it('allows non-tab keys', () => { + const editor = createEditor() + const content = editor.querySelector('.ete-content') + const event = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true }) + content.dispatchEvent(event) + expect(event.defaultPrevented).toBe(false) + }) +})