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
14 changes: 14 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -1081,6 +1081,20 @@ table table-striped table-hover align-middle mb-0

The responsive wrapper is enabled by default through `table-responsive`.

### Sortable header visual polish

Sortable headers render a clearer Bootstrap-friendly control.

The current implementation uses dependency-free textual indicators:

- `↕` for sortable but unsorted columns;
- `↑` for ascending sort;
- `↓` for descending sort.

The active column still exposes `aria-sort`.

The visual indicator is marked `aria-hidden="true", while the accessible state remains available through visually hidden text and ARIA attributes.

### Bootstrap rendering defaults

Bootstrap table display variants can be configured globally.
Expand Down
16 changes: 16 additions & 0 deletions docs/table-controls.md
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,22 @@ Next
Go to page 2
```

## Sortable header indicators

Sortable headers include visual indicators:

```text
↕ unsorted
↑ ascending
↓ descending
```

These indicators do not require an icon library.

The active sorted column also exposes `aria-sort`.

The indicator is decorative and hidden from assistive technologies.

## Loading state

During Ajax refresh, the controller:
Expand Down
27 changes: 22 additions & 5 deletions templates/bootstrap/_header.html.twig
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,24 @@
<tr>
{% for column in visibleColumns %}
{% set is_current_sort = column.name == current_sort_field %}
{% set aria_sort = null %}
{% if is_current_sort %}
{% set aria_sort = current_sort_direction == 'desc' ? 'descending' : 'ascending' %}
{% endif %}

<th
{% if column.className is not null %}
class="{{ column.className }}"
{% endif %}
scope="col"
{% if is_current_sort %}
aria-sort="{{ current_sort_direction == 'desc' ? 'descending' : 'ascending' }}"
{% if aria_sort is not null %}
aria-sort="{{ aria_sort }}"
{% endif %}
>
{% if column.sortable %}
<button
type="button"
class="btn btn-link p-0 text-decoration-none{% if is_current_sort %} active{% endif %}"
class="btn btn-link p-0 text-decoration-none d-inline-flex align-items-center gap-1 zhortein-datatable__sort-button{% if is_current_sort %} active{% endif %}"
data-action="zhortein--datatable-bundle--datatable#sort"
data-zhortein--datatable-bundle--datatable-field-param="{{ column.name }}"
data-zhortein--datatable-bundle--datatable-current-sort-param="{{ is_current_sort ? 'true' : 'false' }}"
Expand All @@ -26,7 +31,19 @@
'%column%': column.label ?? column.name
}, 'zhortein_datatable') }}"
>
{{ column.label ?? column.name }}
<span class="zhortein-datatable__sort-label">
{{ column.label ?? column.name }}
</span>

<span class="zhortein-datatable__sort-indicator text-body-secondary" aria-hidden="true">
{% if is_current_sort and current_sort_direction == 'desc' %}
{% elseif is_current_sort %}
{% else %}
{% endif %}
</span>

{% if is_current_sort %}
<span class="visually-hidden">
Expand All @@ -42,7 +59,7 @@

{% if hasRowActions %}
<th class="text-end" scope="col">
{{ 'zhortein_datatable.actions.more'|trans({}, 'zhortein_datatable') }}
{{ 'zhortein_datatable.actions.label'|trans({}, 'zhortein_datatable') }}
</th>
{% endif %}
</tr>
Expand Down
105 changes: 105 additions & 0 deletions tests/Unit/Renderer/DatatableRendererSortableHeaderPolishTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
<?php

declare(strict_types=1);

namespace Zhortein\DatatableBundle\Tests\Unit\Renderer;

use PHPUnit\Framework\TestCase;
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
use Zhortein\DatatableBundle\Definition\DatatableDefinition;
use Zhortein\DatatableBundle\Renderer\DatatableRenderer;

final class DatatableRendererSortableHeaderPolishTest extends TestCase
{
use TranslatableRendererTestTrait;

public function test_it_renders_neutral_sort_indicator_for_sortable_columns_without_active_sort(): void
{
$html = $this->createRenderer()->renderHeader($this->createDefinition());

self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-target="header"', $html);
self::assertStringContainsString('zhortein-datatable__sort-button', $html);
self::assertStringContainsString('zhortein-datatable__sort-indicator', $html);
self::assertStringContainsString('↕', $html);
self::assertStringContainsString('data-action="zhortein--datatable-bundle--datatable#sort"', $html);
self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-field-param="e.email"', $html);
self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-current-sort-param="false"', $html);
self::assertStringNotContainsString('aria-sort=', $html);
}

public function test_it_renders_ascending_sort_indicator_for_current_sort_column(): void
{
$html = $this->createRenderer()->renderHeader($this->createDefinition(), [
'sortField' => 'e.email',
'sortDirection' => 'asc',
]);

self::assertStringContainsString('aria-sort="ascending"', $html);
self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-current-sort-param="true"', $html);
self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-sort-direction-param="asc"', $html);
self::assertStringContainsString('↑', $html);
self::assertStringContainsString('sorted ascending', $html);
self::assertStringContainsString('active', $html);
}

public function test_it_renders_descending_sort_indicator_for_current_sort_column(): void
{
$html = $this->createRenderer()->renderHeader($this->createDefinition(), [
'sortField' => 'e.email',
'sortDirection' => 'desc',
]);

self::assertStringContainsString('aria-sort="descending"', $html);
self::assertStringContainsString('data-zhortein--datatable-bundle--datatable-sort-direction-param="desc"', $html);
self::assertStringContainsString('↓', $html);
self::assertStringContainsString('sorted descending', $html);
}

public function test_non_sortable_columns_remain_static(): void
{
$html = $this->createRenderer()->renderHeader($this->createDefinition());

self::assertStringContainsString('Created at', $html);
self::assertStringNotContainsString('data-zhortein--datatable-bundle--datatable-field-param="e.createdAt"', $html);
}

public function test_it_preserves_header_column_class(): void
{
$html = $this->createRenderer()->renderHeader($this->createDefinition());

self::assertStringContainsString('class="text-end"', $html);
}

private function createDefinition(): DatatableDefinition
{
$definition = new DatatableDefinition('users');

$definition
->addColumn('e.email', label: 'Email')
->addColumn('e.createdAt', label: 'Created at', sortable: false, className: 'text-end')
;

return $definition;
}

private function createRenderer(): DatatableRenderer
{
return new DatatableRenderer($this->createTwigEnvironment());
}

private function createTwigEnvironment(): Environment
{
$loader = new FilesystemLoader();
$loader->addPath(__DIR__.'/../../../templates', 'ZhorteinDatatable');

$twig = new Environment($loader, [
'strict_variables' => true,
'autoescape' => 'html',
]);

$this->addTranslationExtension($twig);

return $twig;
}
}