diff --git a/config/services.yaml b/config/services.yaml index 3854036..58a444d 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -52,6 +52,10 @@ services: tags: - { name: 'twig.component', key: 'Enabel:Ux:Timeline', template: '@EnabelUx/timeline/timeline.html.twig', expose_public_props: true } + Enabel\Ux\Component\Toast\Toast: + tags: + - { name: 'twig.component', key: 'Enabel:Ux:Toast', template: '@EnabelUx/toast/toast.html.twig', expose_public_props: true } + Enabel\Ux\Component\Widget\Widget: tags: - { name: 'twig.component', key: 'Enabel:Ux:Widget', template: '@EnabelUx/widget/widget.html.twig', expose_public_props: true } diff --git a/docs/Toast/toast.md b/docs/Toast/toast.md new file mode 100644 index 0000000..840d3ff --- /dev/null +++ b/docs/Toast/toast.md @@ -0,0 +1,116 @@ +# Toast Component + +## Description + +A Bootstrap 5 toast component for non-intrusive notifications (flash messages, status updates). Renders a `.toast` element styled with `text-bg-{type}`, with optional dismiss button, auto-hide and configurable politeness for screen readers. + +The component renders the markup only — Bootstrap's toast must be initialized client-side (via `bootstrap.Toast.getOrCreateInstance(el).show()` or any equivalent), exactly like the native Bootstrap toast. + +## Parameters + +| Parameter | Type | Description | Default | +|:--------------|:----------|:---------------------------------------------------------------------------------------------|:----------| +| `text` | `?string` | The message text rendered in the toast body | `null` | +| `type` | `string` | Bootstrap color: `primary`, `secondary`, `success`, `danger`, `warning`, `info`, `light`, `dark` | `'info'` | +| `dismissible` | `bool` | Show a close button | `true` | +| `autohide` | `bool` | Map to Bootstrap `data-bs-autohide` | `true` | +| `delay` | `int` | Auto-hide delay in milliseconds (Bootstrap `data-bs-delay`) | `5000` | +| `politeness` | `string` | `polite` or `assertive`. Maps to `aria-live` | `'polite'`| + +## Accessibility notes + +The component renders `role="alert"` with `aria-live="{politeness}"` and `aria-atomic="true"`. + +- Use `politeness: 'polite'` (default) for non-urgent updates so the screen reader announces them at the next pause (success, info confirmations). +- Use `politeness: 'assertive'` for urgent feedback that must interrupt (errors, warnings the user must act on). + +The close button gets `btn-close-white` automatically except on `warning` and `light` backgrounds (dark text on light fill). + +## Usage + +### Basic toast + +```twig +{{ component('Enabel:Ux:Toast', { + text: 'Saved successfully', + type: 'success', +}) }} +``` + +### Toast with custom delay + +```twig +{{ component('Enabel:Ux:Toast', { + text: 'This will hide in 10 seconds', + type: 'info', + delay: 10000, +}) }} +``` + +### Persistent toast (no auto-hide) + +```twig +{{ component('Enabel:Ux:Toast', { + text: 'Read me carefully and dismiss when done', + type: 'warning', + autohide: false, +}) }} +``` + +### Assertive toast for errors + +```twig +{{ component('Enabel:Ux:Toast', { + text: 'Failed to save your changes', + type: 'danger', + politeness: 'assertive', +}) }} +``` + +### Toast with rich content via the `content` block + +```twig +{% component 'Enabel:Ux:Toast' with { type: 'success' } %} + {% block content %} + Saved. View it here. + {% endblock %} +{% endcomponent %} +``` + +## Showing the toast (client-side) + +Like the native Bootstrap toast, the component renders the markup but does not display it. Initialize and show via Bootstrap's JS API. Example with a Stimulus controller covering all toasts inside a container: + +```js +import { Controller } from '@hotwired/stimulus'; +import { Toast } from 'bootstrap'; + +export default class extends Controller { + connect() { + this.element.querySelectorAll('.toast').forEach(el => { + Toast.getOrCreateInstance(el).show(); + }); + } +} +``` + +```twig +
+ {% for type, messages in app.flashes %} + {% for message in messages %} + {{ component('Enabel:Ux:Toast', { text: message, type: type }) }} + {% endfor %} + {% endfor %} +
+``` + +Bootstrap automatically stacks multiple toasts inside a `.toast-container` (margin between them) and handles fade-in/fade-out animations. + +## Flash type mapping (Symfony) + +Symfony's `addFlash()` accepts free-form types. To map them to Bootstrap colors before passing to the component: + +```twig +{% set bsType = {success: 'success', error: 'danger', warning: 'warning', info: 'info', notice: 'info'}[type]|default('primary') %} +{{ component('Enabel:Ux:Toast', { text: message, type: bsType }) }} +``` diff --git a/docs/index.md b/docs/index.md index acbd1fd..c0fc2ec 100644 --- a/docs/index.md +++ b/docs/index.md @@ -13,6 +13,7 @@ https://github.com/Enabel/Ux - [Card](Card/card.md) - A Bootstrap card component for displaying content in a flexible and extensible container - [Modal](Modal/modal.md) - Easily render modals from a dedicated controller - [Timeline](Timeline/timeline.md) - A Bootstrap timeline component for displaying chronological events with icons and color-coded styling +- [Toast](Toast/toast.md) - A Bootstrap toast component for non-intrusive notifications with auto-hide and accessibility-aware politeness - [Widget](Widget/widget.md) - A Bootstrap widget component for displaying key metrics, statistics, and navigation elements in dashboard-style format - **Navigation Components** - Bootstrap components for creating responsive navigation: - [Navbar](Navigation/navbar.md) - Main navigation container with logo, name, and link configuration diff --git a/src/Component/Toast/Toast.php b/src/Component/Toast/Toast.php new file mode 100644 index 0000000..89702c3 --- /dev/null +++ b/src/Component/Toast/Toast.php @@ -0,0 +1,60 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Component\Toast; + +use Symfony\Component\OptionsResolver\OptionsResolver; +use Symfony\UX\TwigComponent\Attribute\PreMount; + +class Toast +{ + public ?string $text; + public string $type; + public bool $dismissible; + public bool $autohide; + public int $delay; + public string $politeness; + + /** + * @param array $data + * + * @return array + */ + #[PreMount] + public function preMount(array $data): array + { + $resolver = new OptionsResolver(); + $this->configureOptions($resolver); + + return $resolver->resolve($data) + $data; + } + + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setIgnoreUndefined(); + $resolver->setDefaults([ + 'text' => null, + 'type' => 'info', + 'dismissible' => true, + 'autohide' => true, + 'delay' => 5000, + 'politeness' => 'polite', + ]); + + $resolver->setAllowedTypes('text', ['string', 'null']); + $resolver->setAllowedTypes('type', 'string'); + $resolver->setAllowedTypes('dismissible', 'bool'); + $resolver->setAllowedTypes('autohide', 'bool'); + $resolver->setAllowedTypes('delay', 'int'); + $resolver->setAllowedTypes('politeness', 'string'); + + $resolver->setAllowedValues('type', ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark']); + $resolver->setAllowedValues('politeness', ['polite', 'assertive']); + } +} diff --git a/templates/toast/toast.html.twig b/templates/toast/toast.html.twig new file mode 100644 index 0000000..63e41a8 --- /dev/null +++ b/templates/toast/toast.html.twig @@ -0,0 +1,21 @@ +{# Toast component (Bootstrap 5) #} +{% set isLightBg = type in ['warning', 'light'] %} + + diff --git a/tests/Component/Toast/ToastTest.php b/tests/Component/Toast/ToastTest.php new file mode 100644 index 0000000..b56c1f9 --- /dev/null +++ b/tests/Component/Toast/ToastTest.php @@ -0,0 +1,130 @@ + + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Enabel\Ux\Tests\Component\Toast; + +use Enabel\Ux\Component\Toast\Toast; +use PHPUnit\Framework\TestCase; +use Symfony\Component\OptionsResolver\Exception\InvalidOptionsException; + +class ToastTest extends TestCase +{ + public function testComponentCanBeInstantiatedWithDefaultParameters(): void + { + $component = new Toast(); + $data = $component->preMount([]); + + $component->text = $data['text']; + $component->type = $data['type']; + $component->dismissible = $data['dismissible']; + $component->autohide = $data['autohide']; + $component->delay = $data['delay']; + $component->politeness = $data['politeness']; + + $this->assertNull($component->text); + $this->assertSame('info', $component->type); + $this->assertTrue($component->dismissible); + $this->assertTrue($component->autohide); + $this->assertSame(5000, $component->delay); + $this->assertSame('polite', $component->politeness); + } + + public function testComponentCanBeInstantiatedWithCustomParameters(): void + { + $component = new Toast(); + $data = $component->preMount([ + 'text' => 'Saved successfully', + 'type' => 'success', + 'dismissible' => false, + 'autohide' => false, + 'delay' => 10000, + 'politeness' => 'assertive', + ]); + + $component->text = $data['text']; + $component->type = $data['type']; + $component->dismissible = $data['dismissible']; + $component->autohide = $data['autohide']; + $component->delay = $data['delay']; + $component->politeness = $data['politeness']; + + $this->assertSame('Saved successfully', $component->text); + $this->assertSame('success', $component->type); + $this->assertFalse($component->dismissible); + $this->assertFalse($component->autohide); + $this->assertSame(10000, $component->delay); + $this->assertSame('assertive', $component->politeness); + } + + public function testInvalidTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new Toast(); + $component->preMount(['type' => 'unknown']); + } + + public function testValidTypes(): void + { + $component = new Toast(); + foreach (['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'] as $type) { + $data = $component->preMount(['type' => $type]); + $this->assertSame($type, $data['type']); + } + } + + public function testInvalidPolitenessThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new Toast(); + $component->preMount(['politeness' => 'rude']); + } + + public function testInvalidTextTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new Toast(); + $component->preMount(['text' => 123]); + } + + public function testInvalidDismissibleTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new Toast(); + $component->preMount(['dismissible' => 'yes']); + } + + public function testInvalidAutohideTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new Toast(); + $component->preMount(['autohide' => 'yes']); + } + + public function testInvalidDelayTypeThrows(): void + { + $this->expectException(InvalidOptionsException::class); + + $component = new Toast(); + $component->preMount(['delay' => '5000']); + } + + public function testPreMountPreservesAdditionalData(): void + { + $component = new Toast(); + $data = $component->preMount(['text' => 'Hi', 'custom_attribute' => 'value']); + + $this->assertArrayHasKey('custom_attribute', $data); + $this->assertSame('value', $data['custom_attribute']); + } +}