Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions config/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
116 changes: 116 additions & 0 deletions docs/Toast/toast.md
Original file line number Diff line number Diff line change
@@ -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 %}
<strong>Saved.</strong> View it <a href="/items/42" class="link-light">here</a>.
{% 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
<div class="toast-container position-fixed top-0 end-0 p-3" data-controller="flash">
{% for type, messages in app.flashes %}
{% for message in messages %}
{{ component('Enabel:Ux:Toast', { text: message, type: type }) }}
{% endfor %}
{% endfor %}
</div>
```

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 }) }}
```
1 change: 1 addition & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
60 changes: 60 additions & 0 deletions src/Component/Toast/Toast.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
<?php

/*
* This file is part of the Enabel UX package.
* Copyright (c) Enabel <https://enabel.be/>
* 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<string, mixed> $data
*
* @return array<string, mixed>
*/
#[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']);
}
}
21 changes: 21 additions & 0 deletions templates/toast/toast.html.twig
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{# Toast component (Bootstrap 5) #}
{% set isLightBg = type in ['warning', 'light'] %}

<div class="toast align-items-center text-bg-{{ type }} border-0 {{ attributes.render('class') }}"
{{ attributes.defaults({}) }}
role="alert"
aria-live="{{ politeness }}"
aria-atomic="true"
data-bs-autohide="{{ autohide ? 'true' : 'false' }}"
data-bs-delay="{{ delay }}">
<div class="d-flex">
<div class="toast-body">
{% block content %}
{{ text|raw }}
{% endblock %}
</div>
{% if dismissible %}
<button type="button" class="btn-close{% if not isLightBg %} btn-close-white{% endif %} me-2 m-auto" data-bs-dismiss="toast" aria-label="Close"></button>
{% endif %}
</div>
</div>
130 changes: 130 additions & 0 deletions tests/Component/Toast/ToastTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
<?php

/*
* This file is part of the Enabel UX package.
* Copyright (c) Enabel <https://enabel.be/>
* 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']);
}
}
Loading