From 1041fb4f6882b1a5cc596862f54cfc33dee350f4 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 16 Apr 2026 11:11:11 +0200 Subject: [PATCH 1/5] Add responsive DataTable support with collapsible columns per device --- assets/controllers/responsive.js | 91 ++++++++++++ assets/package.json | 5 + assets/styles/responsive.css | 35 +++++ src/Column/Type/ColumnType.php | 10 ++ src/DataTable.php | 14 ++ src/DataTableInterface.php | 5 + src/DependencyInjection/Configuration.php | 8 ++ .../KreyuDataTableExtension.php | 12 ++ src/Request/HttpFoundationRequestHandler.php | 23 ++- .../views/themes/_responsive.html.twig | 130 +++++++++++++++++ src/Resources/views/themes/base.html.twig | 16 ++- .../themes/bootstrap_5_responsive.html.twig | 2 + .../views/themes/tabler_responsive.html.twig | 2 + src/Responsive/Device.php | 33 +++++ src/Responsive/DeviceDetectorInterface.php | 12 ++ src/Responsive/UserAgentDeviceDetector.php | 28 ++++ src/Type/DataTableType.php | 2 + tests/Unit/Column/Type/ColumnTypeTest.php | 43 ++++++ tests/Unit/Responsive/DeviceTest.php | 60 ++++++++ ...ationRequestHandlerDeviceDetectionTest.php | 134 ++++++++++++++++++ .../UserAgentDeviceDetectorTest.php | 79 +++++++++++ 21 files changed, 737 insertions(+), 7 deletions(-) create mode 100644 assets/controllers/responsive.js create mode 100644 assets/styles/responsive.css create mode 100644 src/Resources/views/themes/_responsive.html.twig create mode 100644 src/Resources/views/themes/bootstrap_5_responsive.html.twig create mode 100644 src/Resources/views/themes/tabler_responsive.html.twig create mode 100644 src/Responsive/Device.php create mode 100644 src/Responsive/DeviceDetectorInterface.php create mode 100644 src/Responsive/UserAgentDeviceDetector.php create mode 100644 tests/Unit/Responsive/DeviceTest.php create mode 100644 tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php create mode 100644 tests/Unit/Responsive/UserAgentDeviceDetectorTest.php diff --git a/assets/controllers/responsive.js b/assets/controllers/responsive.js new file mode 100644 index 00000000..6f1f31bb --- /dev/null +++ b/assets/controllers/responsive.js @@ -0,0 +1,91 @@ +import { Controller } from '@hotwired/stimulus' + +/* stimulusFetch: 'eager' */ +export default class extends Controller { + static targets = ['toggleButton', 'collapsibleRow'] + + static values = { + phoneMax: { type: Number, default: 767 }, + tabletMax: { type: Number, default: 1023 }, + parameterName: { type: String, default: '_device' }, + currentDevice: { type: String, default: '' }, + } + + #resizeTimeout = null + #boundResize = null + + connect() { + this.#detectAndUpdate(); + + this.#boundResize = this.#onResize.bind(this); + window.addEventListener('resize', this.#boundResize); + } + + disconnect() { + window.removeEventListener('resize', this.#boundResize); + + if (this.#resizeTimeout) { + clearTimeout(this.#resizeTimeout); + } + } + + toggle(event) { + const button = event.currentTarget; + const index = button.dataset.rowIndex; + const row = this.collapsibleRowTargets.find(r => r.dataset.rowIndex === index); + + if (!row) { + return; + } + + row.hidden = !row.hidden; + button.setAttribute('aria-expanded', String(!row.hidden)); + button.querySelector('.kreyu-dt-toggle-icon').textContent = row.hidden ? '+' : '\u2212'; + } + + #onResize() { + if (this.#resizeTimeout) { + clearTimeout(this.#resizeTimeout); + } + + this.#resizeTimeout = setTimeout(() => this.#detectAndUpdate(), 250); + } + + #detectAndUpdate() { + const device = this.#detectDevice(); + + if (device === this.currentDeviceValue) { + return; + } + + this.currentDeviceValue = device; + this.#reloadFrame(device); + } + + #detectDevice() { + const width = window.innerWidth; + + if (width <= this.phoneMaxValue) { + return 'phone'; + } + + if (width <= this.tabletMaxValue) { + return 'tablet'; + } + + return 'desktop'; + } + + #reloadFrame(device) { + const frame = this.element.closest('turbo-frame'); + + if (!frame) { + return; + } + + const url = new URL(window.location.href); + url.searchParams.set(this.parameterNameValue, device); + + frame.src = url.toString(); + } +} diff --git a/assets/package.json b/assets/package.json index a8a8d2cc..860d6829 100755 --- a/assets/package.json +++ b/assets/package.json @@ -24,6 +24,11 @@ "main": "controllers/bootstrap/modal.js", "fetch": "eager", "enabled": false + }, + "responsive": { + "main": "controllers/responsive.js", + "fetch": "eager", + "enabled": true } }, "importmap": { diff --git a/assets/styles/responsive.css b/assets/styles/responsive.css new file mode 100644 index 00000000..63abae5d --- /dev/null +++ b/assets/styles/responsive.css @@ -0,0 +1,35 @@ +.kreyu-dt-collapsible-row td { + padding: 0 !important; +} + +.kreyu-dt-collapsible-row dl { + display: grid; + grid-template-columns: auto 1fr; + gap: 0.25rem 1rem; + margin: 0; + padding: 0.5rem 0.75rem; +} + +.kreyu-dt-collapsible-row dt { + font-weight: 600; + white-space: nowrap; +} + +.kreyu-dt-collapsible-row dd { + margin: 0; +} + +.kreyu-dt-toggle-btn { + background: none; + border: none; + cursor: pointer; + padding: 0.25rem 0.5rem; + line-height: 1; + font-size: 1.1rem; + color: inherit; + opacity: 0.6; +} + +.kreyu-dt-toggle-btn:hover { + opacity: 1; +} diff --git a/src/Column/Type/ColumnType.php b/src/Column/Type/ColumnType.php index 119f8bdc..1c328540 100755 --- a/src/Column/Type/ColumnType.php +++ b/src/Column/Type/ColumnType.php @@ -8,6 +8,7 @@ use Kreyu\Bundle\DataTableBundle\Column\ColumnHeaderView; use Kreyu\Bundle\DataTableBundle\Column\ColumnInterface; use Kreyu\Bundle\DataTableBundle\Column\ColumnValueView; +use Kreyu\Bundle\DataTableBundle\Responsive\Device; use Kreyu\Bundle\DataTableBundle\Util\StringUtil; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -73,6 +74,7 @@ public function buildHeaderView(ColumnHeaderView $view, ColumnInterface $column, 'sort_direction' => $sortColumnData?->getDirection(), 'sortable' => $column->getConfig()->isSortable(), 'export' => $column->getConfig()->isExportable(), + 'visible_from' => $options['visible_from'], ]); } @@ -120,6 +122,7 @@ public function buildValueView(ColumnValueView $view, ColumnInterface $column, a 'translation_domain' => $translationDomain, 'translation_parameters' => $translationParameters ?? [], 'attr' => $attr, + 'visible_from' => $options['visible_from'], ]); } @@ -330,6 +333,13 @@ public function configureOptions(OptionsResolver $resolver): void ->allowedTypes('bool') ->info('Defines whether the column can be personalized by the user in personalization feature.') ; + + $resolver->define('visible_from') + ->default(Device::Phone) + ->allowedTypes(Device::class, 'bool') + ->allowedValues(static fn ($value) => $value !== true) + ->info('Minimum device from which the column is directly visible (cascade: Phone < Tablet < Desktop). Device::Phone = always visible, Device::Tablet = tablet+desktop, Device::Desktop = desktop only, false = always in collapsible row.') + ; } public function getBlockPrefix(): string diff --git a/src/DataTable.php b/src/DataTable.php index c48a3303..f355fadb 100755 --- a/src/DataTable.php +++ b/src/DataTable.php @@ -39,6 +39,7 @@ use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData; use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; use Kreyu\Bundle\DataTableBundle\Query\ResultSetInterface; +use Kreyu\Bundle\DataTableBundle\Responsive\Device; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; use Symfony\Component\Form\FormBuilderInterface; @@ -113,6 +114,7 @@ class DataTable implements DataTableInterface private bool $initialized = false; private ?string $turboFrameId = null; + private Device $device = Device::Desktop; public function __construct( private ProxyQueryInterface $query, @@ -880,6 +882,18 @@ public function isRequestFromTurboFrame(): bool return null !== $this->turboFrameId && 'kreyu_data_table_'.$this->getName() === $this->turboFrameId; } + public function getDevice(): Device + { + return $this->device; + } + + public function setDevice(Device $device): static + { + $this->device = $device; + + return $this; + } + private function dispatch(string $eventName, DataTableEvent $event): void { $dispatcher = $this->config->getEventDispatcher(); diff --git a/src/DataTableInterface.php b/src/DataTableInterface.php index 177bb90e..e337b70d 100755 --- a/src/DataTableInterface.php +++ b/src/DataTableInterface.php @@ -24,6 +24,7 @@ use Kreyu\Bundle\DataTableBundle\Pagination\PaginationInterface; use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData; use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; +use Kreyu\Bundle\DataTableBundle\Responsive\Device; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; use Symfony\Component\Form\FormBuilderInterface; @@ -219,4 +220,8 @@ public function createExportView(): DataTableView; public function setTurboFrameId(string $turboFrameId): static; public function isRequestFromTurboFrame(): bool; + + public function getDevice(): Device; + + public function setDevice(Device $device): static; } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index e1ec2e20..9494c914 100755 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -151,6 +151,14 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('responsive') + ->addDefaultsIfNotSet() + ->children() + ->booleanNode('enabled') + ->defaultFalse() + ->end() + ->end() + ->end() ->arrayNode('profiler') ->addDefaultsIfNotSet() ->children() diff --git a/src/DependencyInjection/KreyuDataTableExtension.php b/src/DependencyInjection/KreyuDataTableExtension.php index 57b9ddec..e03a0d97 100755 --- a/src/DependencyInjection/KreyuDataTableExtension.php +++ b/src/DependencyInjection/KreyuDataTableExtension.php @@ -15,6 +15,8 @@ use Kreyu\Bundle\DataTableBundle\Filter\Type\FilterTypeInterface; use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryFactoryInterface; +use Kreyu\Bundle\DataTableBundle\Responsive\DeviceDetectorInterface; +use Kreyu\Bundle\DataTableBundle\Responsive\UserAgentDeviceDetector; use Kreyu\Bundle\DataTableBundle\Type\DataTableTypeInterface; use Symfony\Component\AssetMapper\AssetMapperInterface; use Symfony\Component\Config\FileLocator; @@ -76,6 +78,16 @@ public function load(array $configs, ContainerBuilder $container): void ->getDefinition('kreyu_data_table.debug.data_collector') ->setArgument('$maxDepth', $config['profiler']['max_depth']); } + + if ($config['responsive']['enabled'] ?? false) { + $container->register('kreyu_data_table.responsive.device_detector', UserAgentDeviceDetector::class); + $container->setAlias(DeviceDetectorInterface::class, 'kreyu_data_table.responsive.device_detector'); + + $container + ->getDefinition('kreyu_data_table.request_handler.http_foundation') + ->setArgument('$deviceDetector', new Reference('kreyu_data_table.responsive.device_detector')) + ; + } } public function prepend(ContainerBuilder $container): void diff --git a/src/Request/HttpFoundationRequestHandler.php b/src/Request/HttpFoundationRequestHandler.php index c0a78ead..3194353d 100755 --- a/src/Request/HttpFoundationRequestHandler.php +++ b/src/Request/HttpFoundationRequestHandler.php @@ -8,6 +8,8 @@ use Kreyu\Bundle\DataTableBundle\Exception\UnexpectedTypeException; use Kreyu\Bundle\DataTableBundle\Pagination\PaginationData; use Kreyu\Bundle\DataTableBundle\Pagination\PaginationInterface; +use Kreyu\Bundle\DataTableBundle\Responsive\Device; +use Kreyu\Bundle\DataTableBundle\Responsive\DeviceDetectorInterface; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -17,8 +19,9 @@ class HttpFoundationRequestHandler implements RequestHandlerInterface { private readonly PropertyAccessorInterface $propertyAccessor; - public function __construct() - { + public function __construct( + private readonly ?DeviceDetectorInterface $deviceDetector = null, + ) { $this->propertyAccessor = PropertyAccess::createPropertyAccessor(); } @@ -32,6 +35,7 @@ public function handle(DataTableInterface $dataTable, mixed $request = null): vo throw new UnexpectedTypeException($request, Request::class); } + $this->detectDevice($dataTable, $request); $this->filter($dataTable, $request); $this->sort($dataTable, $request); $this->personalize($dataTable, $request); @@ -149,6 +153,21 @@ private function extractQueryParameter(Request $request, string $path): mixed return $this->propertyAccessor->getValue($request->query->all(), $path); } + private function detectDevice(DataTableInterface $dataTable, Request $request): void + { + $deviceParam = $request->query->get('_device'); + + if (null !== $deviceParam && null !== $device = Device::tryFrom($deviceParam)) { + $dataTable->setDevice($device); + + return; + } + + if (null !== $this->deviceDetector) { + $dataTable->setDevice($this->deviceDetector->detect($request)); + } + } + private function turbo(DataTableInterface $dataTable, Request $request): void { $dataTable->setTurboFrameId($request->headers->get('Turbo-Frame')); diff --git a/src/Resources/views/themes/_responsive.html.twig b/src/Resources/views/themes/_responsive.html.twig new file mode 100644 index 00000000..ca600366 --- /dev/null +++ b/src/Resources/views/themes/_responsive.html.twig @@ -0,0 +1,130 @@ +{% trans_default_domain 'KreyuDataTable' %} + +{# Responsive blocks — import via {% use %} in responsive themes #} +{# Provides collapsible rows for columns hidden on the current device #} + +{% block kreyu_data_table %} + {% set stimulus_controllers = ['kreyu--data-table-bundle--state', 'kreyu--data-table-bundle--responsive'] %} + + {% if has_batch_actions %} + {% set stimulus_controllers = stimulus_controllers|merge(['kreyu--data-table-bundle--batch']) %} + {% endif %} + + +
+ {{ block('action_bar', theme) }} + {{ block('table', theme) }} + + {% if pagination_enabled %} + {{ data_table_pagination(pagination) }} + {% endif %} +
+
+{% endblock %} + +{% block kreyu_data_table_attributes %} + data-controller="{{ stimulus_controllers|join(' ') }}" + data-kreyu--data-table-bundle--state-url-query-parameters-value="{{ url_query_parameters|default({})|json_encode(constant('JSON_FORCE_OBJECT')) }}" + data-kreyu--data-table-bundle--responsive-current-device-value="{{ device.value }}" +{% endblock %} + +{% block table_head_row %} + + {% set has_hidden_columns = false %} + + {% for column_header in header_row %} + {% set col_visible_from = column_header.vars.visible_from %} + {% if col_visible_from is same as(false) or (col_visible_from is not same as(false) and not device.isAtLeast(col_visible_from)) %} + {% set has_hidden_columns = true %} + {% endif %} + {% endfor %} + + {% if has_hidden_columns %} + {{ block('responsive_toggle_header', theme) }} + {% endif %} + + {% for column_header in header_row %} + {% set col_visible_from = column_header.vars.visible_from %} + {% if col_visible_from is not same as(false) and device.isAtLeast(col_visible_from) %} + {{- data_table_column_header(column_header) -}} + {% endif %} + {% endfor %} + +{% endblock %} + +{% block table_body_value_row %} + {% set visible_columns = [] %} + {% set hidden_columns = [] %} + + {% for column_name, column_value in value_row %} + {% set col_visible_from = column_value.vars.visible_from %} + {% if col_visible_from is same as(false) or (col_visible_from is not same as(false) and not device.isAtLeast(col_visible_from)) %} + {% set hidden_columns = hidden_columns|merge({ (column_name): column_value }) %} + {% else %} + {% set visible_columns = visible_columns|merge({ (column_name): column_value }) %} + {% endif %} + {% endfor %} + + + {% if hidden_columns|length > 0 %} + {{ block('responsive_toggle_cell', theme) }} + {% endif %} + + {% for column_name, column_value in visible_columns %} + {{- data_table_column_value(column_value) -}} + {% endfor %} + + + {% if hidden_columns|length > 0 %} + {{ block('responsive_collapsible_row', theme) }} + {% endif %} +{% endblock %} + +{% block responsive_toggle_header %} + +{% endblock %} + +{% block responsive_toggle_cell %} + + + +{% endblock %} + +{% block responsive_collapsible_row %} + + +
+ {% for column_name, column_value in hidden_columns %} +
+ {% set col_header = header_row[column_name]|default(null) %} + {% if col_header %} + {% if col_header.vars.translation_domain is not same as false %} + {{ col_header.vars.label|trans(col_header.vars.translation_parameters|default({}), col_header.vars.translation_domain) }} + {% else %} + {{ col_header.vars.label }} + {% endif %} + {% else %} + {{ column_name }} + {% endif %} +
+
{{- data_table_column_value(column_value) -}}
+ {% endfor %} +
+ + +{% endblock %} diff --git a/src/Resources/views/themes/base.html.twig b/src/Resources/views/themes/base.html.twig index 37534b7f..c3cc0ae2 100755 --- a/src/Resources/views/themes/base.html.twig +++ b/src/Resources/views/themes/base.html.twig @@ -10,10 +10,7 @@ {% endif %} -
+
{{ block('action_bar', theme) }} {{ block('table', theme) }} @@ -24,6 +21,11 @@ {% endblock %} +{% block kreyu_data_table_attributes %} + data-controller="{{ stimulus_controllers|join(' ') }}" + data-kreyu--data-table-bundle--state-url-query-parameters-value="{{ url_query_parameters|default({})|json_encode(constant('JSON_FORCE_OBJECT')) }}" +{% endblock %} + {% block kreyu_data_table_form_aware %} {% deprecated 'The "kreyu_data_table_form_aware" block is deprecated. Instead of wrapping the data table with form, reference it by using the "form" HTML attribute.' %} @@ -90,10 +92,14 @@ {% block table_body_row_results %} {% for value_row in value_rows %} - {{ data_table_value_row(value_row) }} + {{ block('table_body_value_row', theme) }} {% endfor %} {% endblock %} +{% block table_body_value_row %} + {{ data_table_value_row(value_row) }} +{% endblock %} + {% block table_body_row_no_results %} {{ 'No results found'|trans({}, 'KreyuDataTable') }} diff --git a/src/Resources/views/themes/bootstrap_5_responsive.html.twig b/src/Resources/views/themes/bootstrap_5_responsive.html.twig new file mode 100644 index 00000000..d2195302 --- /dev/null +++ b/src/Resources/views/themes/bootstrap_5_responsive.html.twig @@ -0,0 +1,2 @@ +{% extends '@KreyuDataTable/themes/bootstrap_5.html.twig' %} +{% use '@KreyuDataTable/themes/_responsive.html.twig' %} diff --git a/src/Resources/views/themes/tabler_responsive.html.twig b/src/Resources/views/themes/tabler_responsive.html.twig new file mode 100644 index 00000000..c2cf7a0c --- /dev/null +++ b/src/Resources/views/themes/tabler_responsive.html.twig @@ -0,0 +1,2 @@ +{% extends '@KreyuDataTable/themes/tabler.html.twig' %} +{% use '@KreyuDataTable/themes/_responsive.html.twig' %} diff --git a/src/Responsive/Device.php b/src/Responsive/Device.php new file mode 100644 index 00000000..707ebace --- /dev/null +++ b/src/Responsive/Device.php @@ -0,0 +1,33 @@ +order() >= $minimum->order(); + } + + private function order(): int + { + return match ($this) { + self::Phone => 0, + self::Tablet => 1, + self::Desktop => 2, + }; + } +} diff --git a/src/Responsive/DeviceDetectorInterface.php b/src/Responsive/DeviceDetectorInterface.php new file mode 100644 index 00000000..504ae0d8 --- /dev/null +++ b/src/Responsive/DeviceDetectorInterface.php @@ -0,0 +1,12 @@ +headers->get('User-Agent', ''); + + if (preg_match(self::TABLET_PATTERN, $userAgent)) { + return Device::Tablet; + } + + if (preg_match(self::PHONE_PATTERN, $userAgent)) { + return Device::Phone; + } + + return Device::Desktop; + } +} diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index e76e935a..a1030703 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -22,6 +22,7 @@ use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; use Kreyu\Bundle\DataTableBundle\Request\RequestHandlerInterface; +use Kreyu\Bundle\DataTableBundle\Responsive\Device; use Kreyu\Bundle\DataTableBundle\RowIterator; use Kreyu\Bundle\DataTableBundle\Util\FormUtil; use Kreyu\Bundle\DataTableBundle\ValueRowView; @@ -107,6 +108,7 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar 'sorting_clearable' => $dataTable->getConfig()->isSortingClearable(), 'has_batch_actions' => !empty($dataTable->getBatchActions()), 'per_page_choices' => $options['per_page_choices'], + 'device' => $dataTable->getDevice(), ]); $view->headerRow = $this->createHeaderRowView($view, $dataTable, $visibleColumns); diff --git a/tests/Unit/Column/Type/ColumnTypeTest.php b/tests/Unit/Column/Type/ColumnTypeTest.php index 358e19bd..594818e0 100644 --- a/tests/Unit/Column/Type/ColumnTypeTest.php +++ b/tests/Unit/Column/Type/ColumnTypeTest.php @@ -9,6 +9,7 @@ use Kreyu\Bundle\DataTableBundle\Column\Type\ColumnTypeInterface; use Kreyu\Bundle\DataTableBundle\Column\Type\ResolvedColumnTypeInterface; use Kreyu\Bundle\DataTableBundle\DataTableView; +use Kreyu\Bundle\DataTableBundle\Responsive\Device; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; use Kreyu\Bundle\DataTableBundle\Test\Column\Type\ColumnTypeTestCase; use Kreyu\Bundle\DataTableBundle\Tests\Fixtures\Model\User; @@ -1058,6 +1059,48 @@ public function testBlockPrefixesWithParent() $this->assertEquals($expectedBlockPrefixes, $columnValueView->vars['block_prefixes']); } + public function testDefaultVisibleFromIsPhone(): void + { + $column = $this->createNamedColumn('firstName'); + + $columnHeaderView = $this->createColumnHeaderView($column); + $columnValueView = $this->createColumnValueView($column); + + $this->assertSame(Device::Phone, $columnHeaderView->vars['visible_from']); + $this->assertSame(Device::Phone, $columnValueView->vars['visible_from']); + } + + #[DataProvider('provideVisibleFromOptions')] + public function testPassingVisibleFromOption(Device|false $visibleFrom): void + { + $column = $this->createNamedColumn('firstName', [ + 'visible_from' => $visibleFrom, + ]); + + $columnHeaderView = $this->createColumnHeaderView($column); + $columnValueView = $this->createColumnValueView($column); + + $this->assertSame($visibleFrom, $columnHeaderView->vars['visible_from']); + $this->assertSame($visibleFrom, $columnValueView->vars['visible_from']); + } + + public static function provideVisibleFromOptions(): iterable + { + yield 'phone' => [Device::Phone]; + yield 'tablet' => [Device::Tablet]; + yield 'desktop' => [Device::Desktop]; + yield 'false' => [false]; + } + + public function testVisibleFromRejectsTrueValue(): void + { + $this->expectException(\Symfony\Component\OptionsResolver\Exception\InvalidOptionsException::class); + + $this->createNamedColumn('firstName', [ + 'visible_from' => true, + ]); + } + protected function expectTranslation( string $expected, string $id, diff --git a/tests/Unit/Responsive/DeviceTest.php b/tests/Unit/Responsive/DeviceTest.php new file mode 100644 index 00000000..9e30b110 --- /dev/null +++ b/tests/Unit/Responsive/DeviceTest.php @@ -0,0 +1,60 @@ +assertSame($expected, $device->isAtLeast($minimum)); + } + + public static function provideIsAtLeastCases(): iterable + { + // Phone is at least Phone + yield 'phone >= phone' => [Device::Phone, Device::Phone, true]; + // Phone is NOT at least Tablet + yield 'phone >= tablet' => [Device::Phone, Device::Tablet, false]; + // Phone is NOT at least Desktop + yield 'phone >= desktop' => [Device::Phone, Device::Desktop, false]; + + // Tablet is at least Phone + yield 'tablet >= phone' => [Device::Tablet, Device::Phone, true]; + // Tablet is at least Tablet + yield 'tablet >= tablet' => [Device::Tablet, Device::Tablet, true]; + // Tablet is NOT at least Desktop + yield 'tablet >= desktop' => [Device::Tablet, Device::Desktop, false]; + + // Desktop is at least everything + yield 'desktop >= phone' => [Device::Desktop, Device::Phone, true]; + yield 'desktop >= tablet' => [Device::Desktop, Device::Tablet, true]; + yield 'desktop >= desktop' => [Device::Desktop, Device::Desktop, true]; + } + + public function testStringValues(): void + { + $this->assertSame('phone', Device::Phone->value); + $this->assertSame('tablet', Device::Tablet->value); + $this->assertSame('desktop', Device::Desktop->value); + } + + public function testTryFromValidValues(): void + { + $this->assertSame(Device::Phone, Device::tryFrom('phone')); + $this->assertSame(Device::Tablet, Device::tryFrom('tablet')); + $this->assertSame(Device::Desktop, Device::tryFrom('desktop')); + } + + public function testTryFromInvalidValue(): void + { + $this->assertNull(Device::tryFrom('invalid')); + $this->assertNull(Device::tryFrom('')); + } +} diff --git a/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php b/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php new file mode 100644 index 00000000..e462d141 --- /dev/null +++ b/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php @@ -0,0 +1,134 @@ +createMock(DeviceDetectorInterface::class); + $detector->expects($this->never())->method('detect'); + + $handler = new HttpFoundationRequestHandler($detector); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setDevice') + ->with(Device::Phone); + + $handler->handle($dataTable, $this->createRequest(['_device' => 'phone'])); + } + + public function testFallsBackToUserAgentWhenNoDeviceParameter(): void + { + $detector = $this->createMock(DeviceDetectorInterface::class); + $detector->expects($this->once()) + ->method('detect') + ->willReturn(Device::Tablet); + + $handler = new HttpFoundationRequestHandler($detector); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setDevice') + ->with(Device::Tablet); + + $handler->handle($dataTable, $this->createRequest()); + } + + public function testFallsBackToUserAgentWhenDeviceParameterIsInvalid(): void + { + $detector = $this->createMock(DeviceDetectorInterface::class); + $detector->expects($this->once()) + ->method('detect') + ->willReturn(Device::Desktop); + + $handler = new HttpFoundationRequestHandler($detector); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setDevice') + ->with(Device::Desktop); + + $handler->handle($dataTable, $this->createRequest(['_device' => 'invalid'])); + } + + public function testNoDeviceDetectionWithoutDetector(): void + { + $handler = new HttpFoundationRequestHandler(); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->never())->method('setDevice'); + + $handler->handle($dataTable, $this->createRequest()); + } + + public function testDeviceParameterWorksWithoutDetector(): void + { + $handler = new HttpFoundationRequestHandler(); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setDevice') + ->with(Device::Tablet); + + $handler->handle($dataTable, $this->createRequest(['_device' => 'tablet'])); + } + + public function testAllDeviceParameterValues(): void + { + $handler = new HttpFoundationRequestHandler(); + + foreach (Device::cases() as $device) { + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setDevice') + ->with($device); + + $handler->handle($dataTable, $this->createRequest(['_device' => $device->value])); + } + } + + public function testNullRequestDoesNothing(): void + { + $handler = new HttpFoundationRequestHandler(); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->never())->method('setDevice'); + + $handler->handle($dataTable, null); + } + + private function createRequest(array $query = []): Request + { + $request = Request::create('/?' . http_build_query($query)); + $request->headers->set('Turbo-Frame', 'kreyu_data_table_test'); + + return $request; + } + + private function createDataTableMock(): DataTableInterface&\PHPUnit\Framework\MockObject\MockObject + { + $config = $this->createMock(DataTableConfigInterface::class); + $config->method('isFiltrationEnabled')->willReturn(false); + $config->method('isSortingEnabled')->willReturn(false); + $config->method('isPaginationEnabled')->willReturn(false); + $config->method('isPersonalizationEnabled')->willReturn(false); + $config->method('isExportingEnabled')->willReturn(false); + + $dataTable = $this->createMock(DataTableInterface::class); + $dataTable->method('getConfig')->willReturn($config); + + return $dataTable; + } +} diff --git a/tests/Unit/Responsive/UserAgentDeviceDetectorTest.php b/tests/Unit/Responsive/UserAgentDeviceDetectorTest.php new file mode 100644 index 00000000..a5679780 --- /dev/null +++ b/tests/Unit/Responsive/UserAgentDeviceDetectorTest.php @@ -0,0 +1,79 @@ +detector = new UserAgentDeviceDetector(); + } + + #[DataProvider('providePhoneUserAgents')] + public function testDetectsPhone(string $userAgent): void + { + $request = Request::create('/', server: ['HTTP_USER_AGENT' => $userAgent]); + + $this->assertSame(Device::Phone, $this->detector->detect($request)); + } + + public static function providePhoneUserAgents(): iterable + { + yield 'iPhone Safari' => ['Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1']; + yield 'Android Chrome Mobile' => ['Mozilla/5.0 (Linux; Android 14; Pixel 8) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36']; + yield 'iPod' => ['Mozilla/5.0 (iPod touch; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.0 Mobile/15E148 Safari/604.1']; + yield 'Windows Phone' => ['Mozilla/5.0 (Windows Phone 10.0; Android 6.0.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/70.0.3538.102 Mobile Safari/537.36']; + yield 'Opera Mini' => ['Opera/9.80 (J2ME/MIDP; Opera Mini/9.80/191.308; U; en) Presto/2.5.25 Version/10.54']; + yield 'BlackBerry' => ['Mozilla/5.0 (BlackBerry; U; BlackBerry 9900; en) AppleWebKit/534.11+ (KHTML, like Gecko) Version/7.1.0.346 Mobile Safari/534.11+']; + } + + #[DataProvider('provideTabletUserAgents')] + public function testDetectsTablet(string $userAgent): void + { + $request = Request::create('/', server: ['HTTP_USER_AGENT' => $userAgent]); + + $this->assertSame(Device::Tablet, $this->detector->detect($request)); + } + + public static function provideTabletUserAgents(): iterable + { + yield 'iPad Safari' => ['Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1']; + yield 'Android Tablet' => ['Mozilla/5.0 (Linux; Android 13; SM-X200) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36']; + yield 'Kindle Fire' => ['Mozilla/5.0 (Linux; Android 11; Silk/95.3.7) AppleWebKit/537.36 (KHTML, like Gecko) Silk/95.3.7 like Chrome/95.0.4638.74 Safari/537.36']; + yield 'PlayBook' => ['Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+']; + } + + #[DataProvider('provideDesktopUserAgents')] + public function testDetectsDesktop(string $userAgent): void + { + $request = Request::create('/', server: ['HTTP_USER_AGENT' => $userAgent]); + + $this->assertSame(Device::Desktop, $this->detector->detect($request)); + } + + public static function provideDesktopUserAgents(): iterable + { + yield 'Chrome Windows' => ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36']; + yield 'Firefox Linux' => ['Mozilla/5.0 (X11; Linux x86_64; rv:120.0) Gecko/20100101 Firefox/120.0']; + yield 'Safari macOS' => ['Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Safari/605.1.15']; + yield 'Edge Windows' => ['Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.0']; + } + + public function testEmptyUserAgentDefaultsToDesktop(): void + { + $request = Request::create('/'); + $request->headers->remove('User-Agent'); + + $this->assertSame(Device::Desktop, $this->detector->detect($request)); + } +} From f0476b3f3052a6313ec873e2ff527a180f7e9c04 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 16 Apr 2026 14:54:26 +0200 Subject: [PATCH 2/5] Replace Device enum with configurable string breakpoints and ResizeObserver --- assets/controllers/responsive.js | 121 +++++++++----- src/Column/Type/ColumnType.php | 7 +- src/DataTable.php | 11 +- src/DataTableInterface.php | 5 +- src/DependencyInjection/Configuration.php | 10 ++ .../KreyuDataTableExtension.php | 5 +- src/Request/HttpFoundationRequestHandler.php | 13 +- .../views/themes/_responsive.html.twig | 20 ++- src/Responsive/Breakpoint.php | 13 ++ src/Responsive/BreakpointResolver.php | 79 +++++++++ src/Type/DataTableType.php | 30 +++- tests/Unit/Column/Type/ColumnTypeTest.php | 19 ++- .../Responsive/BreakpointResolverTest.php | 156 ++++++++++++++++++ ...ationRequestHandlerDeviceDetectionTest.php | 75 +++++---- 14 files changed, 455 insertions(+), 109 deletions(-) create mode 100644 src/Responsive/Breakpoint.php create mode 100644 src/Responsive/BreakpointResolver.php create mode 100644 tests/Unit/Responsive/BreakpointResolverTest.php diff --git a/assets/controllers/responsive.js b/assets/controllers/responsive.js index 6f1f31bb..cde35a6e 100644 --- a/assets/controllers/responsive.js +++ b/assets/controllers/responsive.js @@ -5,87 +5,120 @@ export default class extends Controller { static targets = ['toggleButton', 'collapsibleRow'] static values = { - phoneMax: { type: Number, default: 767 }, - tabletMax: { type: Number, default: 1023 }, - parameterName: { type: String, default: '_device' }, - currentDevice: { type: String, default: '' }, + breakpoints: { type: Object, default: {} }, + parameterName: { type: String, default: '_breakpoint' }, + currentBreakpoint: { type: String, default: '' }, } - #resizeTimeout = null - #boundResize = null + #resizeObserver = null + #debounceTimeout = null connect() { - this.#detectAndUpdate(); + const frame = this.element.closest('turbo-frame') - this.#boundResize = this.#onResize.bind(this); - window.addEventListener('resize', this.#boundResize); + if (!frame) { + this.#reveal() + return + } + + // Synchronous check before first paint: does the server breakpoint match reality? + const width = Math.round(frame.getBoundingClientRect().width) + + if (width > 0) { + const breakpoint = this.#resolveBreakpoint(width) + + if (breakpoint !== this.currentBreakpointValue) { + // Mismatch: keep hidden (CSS class), reload with correct breakpoint + this.currentBreakpointValue = breakpoint + this.#reloadFrame(breakpoint) + } else { + // Match: reveal immediately + this.#reveal() + } + } else { + this.#reveal() + } + + this.#resizeObserver = new ResizeObserver((entries) => { + this.#onResize(Math.round(entries[0].contentRect.width)) + }) + this.#resizeObserver.observe(frame) } disconnect() { - window.removeEventListener('resize', this.#boundResize); + if (this.#resizeObserver) { + this.#resizeObserver.disconnect() + this.#resizeObserver = null + } - if (this.#resizeTimeout) { - clearTimeout(this.#resizeTimeout); + if (this.#debounceTimeout) { + clearTimeout(this.#debounceTimeout) + this.#debounceTimeout = null } } toggle(event) { - const button = event.currentTarget; - const index = button.dataset.rowIndex; - const row = this.collapsibleRowTargets.find(r => r.dataset.rowIndex === index); + const button = event.currentTarget + const index = button.dataset.rowIndex + const row = this.collapsibleRowTargets.find(r => r.dataset.rowIndex === index) if (!row) { - return; + return } - row.hidden = !row.hidden; - button.setAttribute('aria-expanded', String(!row.hidden)); - button.querySelector('.kreyu-dt-toggle-icon').textContent = row.hidden ? '+' : '\u2212'; + row.hidden = !row.hidden + button.setAttribute('aria-expanded', String(!row.hidden)) + button.querySelector('.kreyu-dt-toggle-icon').textContent = row.hidden ? '+' : '\u2212' + } + + #reveal() { + this.element.classList.remove('kreyu-dt-responsive-pending') } - #onResize() { - if (this.#resizeTimeout) { - clearTimeout(this.#resizeTimeout); + #onResize(width) { + if (this.#debounceTimeout) { + clearTimeout(this.#debounceTimeout) } - this.#resizeTimeout = setTimeout(() => this.#detectAndUpdate(), 250); + this.#debounceTimeout = setTimeout(() => this.#detectAndUpdate(width), 250) } - #detectAndUpdate() { - const device = this.#detectDevice(); + #detectAndUpdate(width) { + const breakpoint = this.#resolveBreakpoint(width) - if (device === this.currentDeviceValue) { - return; + if (breakpoint === this.currentBreakpointValue) { + return } - this.currentDeviceValue = device; - this.#reloadFrame(device); + this.currentBreakpointValue = breakpoint + this.#reloadFrame(breakpoint) } - #detectDevice() { - const width = window.innerWidth; - - if (width <= this.phoneMaxValue) { - return 'phone'; - } + #resolveBreakpoint(width) { + const breakpoints = this.breakpointsValue - if (width <= this.tabletMaxValue) { - return 'tablet'; + for (const [name, maxWidth] of Object.entries(breakpoints)) { + if (width <= maxWidth) { + return name + } } - return 'desktop'; + // Above all breakpoints: return the largest one + const names = Object.keys(breakpoints) + return names.length > 0 ? names[names.length - 1] : '' } - #reloadFrame(device) { - const frame = this.element.closest('turbo-frame'); + #reloadFrame(breakpoint) { + const frame = this.element.closest('turbo-frame') if (!frame) { - return; + return } - const url = new URL(window.location.href); - url.searchParams.set(this.parameterNameValue, device); + const baseUrl = frame.getAttribute('src') || window.location.href + const url = new URL(baseUrl, window.location.origin) + url.searchParams.set(this.parameterNameValue, breakpoint) - frame.src = url.toString(); + frame.src = url.toString() } } diff --git a/src/Column/Type/ColumnType.php b/src/Column/Type/ColumnType.php index 1c328540..f96329e5 100755 --- a/src/Column/Type/ColumnType.php +++ b/src/Column/Type/ColumnType.php @@ -8,7 +8,6 @@ use Kreyu\Bundle\DataTableBundle\Column\ColumnHeaderView; use Kreyu\Bundle\DataTableBundle\Column\ColumnInterface; use Kreyu\Bundle\DataTableBundle\Column\ColumnValueView; -use Kreyu\Bundle\DataTableBundle\Responsive\Device; use Kreyu\Bundle\DataTableBundle\Util\StringUtil; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\PropertyAccess\PropertyAccess; @@ -335,10 +334,10 @@ public function configureOptions(OptionsResolver $resolver): void ; $resolver->define('visible_from') - ->default(Device::Phone) - ->allowedTypes(Device::class, 'bool') + ->default(null) + ->allowedTypes('null', 'string', 'bool') ->allowedValues(static fn ($value) => $value !== true) - ->info('Minimum device from which the column is directly visible (cascade: Phone < Tablet < Desktop). Device::Phone = always visible, Device::Tablet = tablet+desktop, Device::Desktop = desktop only, false = always in collapsible row.') + ->info('Minimum breakpoint from which the column is directly visible. null = always visible, string (e.g. "md") = visible from that breakpoint, false = always in collapsible row.') ; } diff --git a/src/DataTable.php b/src/DataTable.php index f355fadb..2006abe6 100755 --- a/src/DataTable.php +++ b/src/DataTable.php @@ -39,7 +39,6 @@ use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData; use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; use Kreyu\Bundle\DataTableBundle\Query\ResultSetInterface; -use Kreyu\Bundle\DataTableBundle\Responsive\Device; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; use Symfony\Component\Form\FormBuilderInterface; @@ -114,7 +113,7 @@ class DataTable implements DataTableInterface private bool $initialized = false; private ?string $turboFrameId = null; - private Device $device = Device::Desktop; + private ?string $activeBreakpoint = null; public function __construct( private ProxyQueryInterface $query, @@ -882,14 +881,14 @@ public function isRequestFromTurboFrame(): bool return null !== $this->turboFrameId && 'kreyu_data_table_'.$this->getName() === $this->turboFrameId; } - public function getDevice(): Device + public function getActiveBreakpoint(): ?string { - return $this->device; + return $this->activeBreakpoint; } - public function setDevice(Device $device): static + public function setActiveBreakpoint(?string $activeBreakpoint): static { - $this->device = $device; + $this->activeBreakpoint = $activeBreakpoint; return $this; } diff --git a/src/DataTableInterface.php b/src/DataTableInterface.php index e337b70d..011cc22d 100755 --- a/src/DataTableInterface.php +++ b/src/DataTableInterface.php @@ -24,7 +24,6 @@ use Kreyu\Bundle\DataTableBundle\Pagination\PaginationInterface; use Kreyu\Bundle\DataTableBundle\Personalization\PersonalizationData; use Kreyu\Bundle\DataTableBundle\Query\ProxyQueryInterface; -use Kreyu\Bundle\DataTableBundle\Responsive\Device; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; use Symfony\Component\Form\FormBuilderInterface; @@ -221,7 +220,7 @@ public function setTurboFrameId(string $turboFrameId): static; public function isRequestFromTurboFrame(): bool; - public function getDevice(): Device; + public function getActiveBreakpoint(): ?string; - public function setDevice(Device $device): static; + public function setActiveBreakpoint(?string $activeBreakpoint): static; } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index 9494c914..dc7a58c8 100755 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -157,6 +157,16 @@ public function getConfigTreeBuilder(): TreeBuilder ->booleanNode('enabled') ->defaultFalse() ->end() + ->arrayNode('breakpoints') + ->useAttributeAsKey('name') + ->defaultValue([ + 'sm' => 576, + 'md' => 768, + 'lg' => 992, + 'xl' => 1200, + ]) + ->prototype('integer')->end() + ->end() ->end() ->end() ->arrayNode('profiler') diff --git a/src/DependencyInjection/KreyuDataTableExtension.php b/src/DependencyInjection/KreyuDataTableExtension.php index e03a0d97..b24b9b7e 100755 --- a/src/DependencyInjection/KreyuDataTableExtension.php +++ b/src/DependencyInjection/KreyuDataTableExtension.php @@ -68,9 +68,12 @@ public function load(array $configs, ContainerBuilder $container): void $container->registerForAutoconfiguration($interface)->addTag($tag); } + $defaults = $config['defaults']; + $defaults['responsive'] = $config['responsive']; + $container ->getDefinition('kreyu_data_table.type.data_table') - ->setArgument('$defaults', $config['defaults']) + ->setArgument('$defaults', $defaults) ; if ($container->getParameter('kernel.debug')) { diff --git a/src/Request/HttpFoundationRequestHandler.php b/src/Request/HttpFoundationRequestHandler.php index 3194353d..e7ddc18f 100755 --- a/src/Request/HttpFoundationRequestHandler.php +++ b/src/Request/HttpFoundationRequestHandler.php @@ -8,7 +8,7 @@ use Kreyu\Bundle\DataTableBundle\Exception\UnexpectedTypeException; use Kreyu\Bundle\DataTableBundle\Pagination\PaginationData; use Kreyu\Bundle\DataTableBundle\Pagination\PaginationInterface; -use Kreyu\Bundle\DataTableBundle\Responsive\Device; +use Kreyu\Bundle\DataTableBundle\Responsive\BreakpointResolver; use Kreyu\Bundle\DataTableBundle\Responsive\DeviceDetectorInterface; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; use Symfony\Component\HttpFoundation\Request; @@ -155,16 +155,19 @@ private function extractQueryParameter(Request $request, string $path): mixed private function detectDevice(DataTableInterface $dataTable, Request $request): void { - $deviceParam = $request->query->get('_device'); + $breakpoint = $request->query->get('_breakpoint'); - if (null !== $deviceParam && null !== $device = Device::tryFrom($deviceParam)) { - $dataTable->setDevice($device); + if (null !== $breakpoint) { + $dataTable->setActiveBreakpoint($breakpoint); return; } if (null !== $this->deviceDetector) { - $dataTable->setDevice($this->deviceDetector->detect($request)); + $device = $this->deviceDetector->detect($request); + $breakpoints = $dataTable->getConfig()->getOption('responsive_breakpoints') ?? []; + $resolver = new BreakpointResolver($breakpoints); + $dataTable->setActiveBreakpoint($resolver->resolveUaFallback($device)); } } diff --git a/src/Resources/views/themes/_responsive.html.twig b/src/Resources/views/themes/_responsive.html.twig index ca600366..60658ce0 100644 --- a/src/Resources/views/themes/_responsive.html.twig +++ b/src/Resources/views/themes/_responsive.html.twig @@ -1,7 +1,7 @@ {% trans_default_domain 'KreyuDataTable' %} {# Responsive blocks — import via {% use %} in responsive themes #} -{# Provides collapsible rows for columns hidden on the current device #} +{# Provides collapsible rows for columns hidden at the current breakpoint #} {% block kreyu_data_table %} {% set stimulus_controllers = ['kreyu--data-table-bundle--state', 'kreyu--data-table-bundle--responsive'] %} @@ -11,7 +11,10 @@ {% endif %} -
+ +
{{ block('action_bar', theme) }} {{ block('table', theme) }} @@ -25,7 +28,8 @@ {% block kreyu_data_table_attributes %} data-controller="{{ stimulus_controllers|join(' ') }}" data-kreyu--data-table-bundle--state-url-query-parameters-value="{{ url_query_parameters|default({})|json_encode(constant('JSON_FORCE_OBJECT')) }}" - data-kreyu--data-table-bundle--responsive-current-device-value="{{ device.value }}" + data-kreyu--data-table-bundle--responsive-breakpoints-value="{{ responsive_breakpoints|json_encode }}" + data-kreyu--data-table-bundle--responsive-current-breakpoint-value="{{ active_breakpoint|default('') }}" {% endblock %} {% block table_head_row %} @@ -34,7 +38,9 @@ {% for column_header in header_row %} {% set col_visible_from = column_header.vars.visible_from %} - {% if col_visible_from is same as(false) or (col_visible_from is not same as(false) and not device.isAtLeast(col_visible_from)) %} + {% if col_visible_from is same as(false) %} + {% set has_hidden_columns = true %} + {% elseif col_visible_from is not null and active_breakpoint is not null and not breakpoint_resolver.isVisible(active_breakpoint, col_visible_from) %} {% set has_hidden_columns = true %} {% endif %} {% endfor %} @@ -45,7 +51,7 @@ {% for column_header in header_row %} {% set col_visible_from = column_header.vars.visible_from %} - {% if col_visible_from is not same as(false) and device.isAtLeast(col_visible_from) %} + {% if col_visible_from is not same as(false) and (col_visible_from is null or active_breakpoint is null or breakpoint_resolver.isVisible(active_breakpoint, col_visible_from)) %} {{- data_table_column_header(column_header) -}} {% endif %} {% endfor %} @@ -58,7 +64,9 @@ {% for column_name, column_value in value_row %} {% set col_visible_from = column_value.vars.visible_from %} - {% if col_visible_from is same as(false) or (col_visible_from is not same as(false) and not device.isAtLeast(col_visible_from)) %} + {% if col_visible_from is same as(false) %} + {% set hidden_columns = hidden_columns|merge({ (column_name): column_value }) %} + {% elseif col_visible_from is not null and active_breakpoint is not null and not breakpoint_resolver.isVisible(active_breakpoint, col_visible_from) %} {% set hidden_columns = hidden_columns|merge({ (column_name): column_value }) %} {% else %} {% set visible_columns = visible_columns|merge({ (column_name): column_value }) %} diff --git a/src/Responsive/Breakpoint.php b/src/Responsive/Breakpoint.php new file mode 100644 index 00000000..8bf0ebad --- /dev/null +++ b/src/Responsive/Breakpoint.php @@ -0,0 +1,13 @@ + $breakpoints Associative array of name => max width, sorted ascending + */ + public function __construct( + private readonly array $breakpoints, + ) { + } + + /** + * Resolves a pixel width to the name of the active breakpoint. + * + * Returns the largest configured breakpoint when width exceeds all thresholds. + */ + public function resolve(int $width): string + { + foreach ($this->breakpoints as $name => $maxWidth) { + if ($width <= $maxWidth) { + return $name; + } + } + + return array_key_last($this->breakpoints); + } + + /** + * Checks whether a column is visible at the given active breakpoint. + * + * A column with $minimumBreakpoint is visible when the active breakpoint + * is at the same position or higher in the configured breakpoints order. + */ + public function isVisible(string $activeBreakpoint, string $minimumBreakpoint): bool + { + $names = array_keys($this->breakpoints); + $activeIndex = array_search($activeBreakpoint, $names, true); + $minimumIndex = array_search($minimumBreakpoint, $names, true); + + if (false === $minimumIndex) { + return true; + } + + return $activeIndex >= $minimumIndex; + } + + /** + * @return array + */ + public function getBreakpoints(): array + { + return $this->breakpoints; + } + + /** + * Resolves a User-Agent device type to a fallback breakpoint name. + * + * Phone → smallest breakpoint, Tablet → median breakpoint, Desktop → largest breakpoint. + */ + public function resolveUaFallback(Device $device): ?string + { + $names = array_keys($this->breakpoints); + + if ([] === $names) { + return null; + } + + return match ($device) { + Device::Phone => $names[0], + Device::Tablet => $names[(int) floor(count($names) / 2)], + Device::Desktop => $names[count($names) - 1], + }; + } +} diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index a1030703..b2b1a5d3 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -12,6 +12,7 @@ use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; use Kreyu\Bundle\DataTableBundle\DataTableInterface; use Kreyu\Bundle\DataTableBundle\DataTableView; +use Kreyu\Bundle\DataTableBundle\Exception\InvalidArgumentException; use Kreyu\Bundle\DataTableBundle\Exporter\ExporterFactoryInterface; use Kreyu\Bundle\DataTableBundle\Filter\FilterData; use Kreyu\Bundle\DataTableBundle\Filter\FilterFactoryInterface; @@ -22,7 +23,7 @@ use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceAdapterInterface; use Kreyu\Bundle\DataTableBundle\Persistence\PersistenceSubjectProviderInterface; use Kreyu\Bundle\DataTableBundle\Request\RequestHandlerInterface; -use Kreyu\Bundle\DataTableBundle\Responsive\Device; +use Kreyu\Bundle\DataTableBundle\Responsive\BreakpointResolver; use Kreyu\Bundle\DataTableBundle\RowIterator; use Kreyu\Bundle\DataTableBundle\Util\FormUtil; use Kreyu\Bundle\DataTableBundle\ValueRowView; @@ -108,9 +109,27 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar 'sorting_clearable' => $dataTable->getConfig()->isSortingClearable(), 'has_batch_actions' => !empty($dataTable->getBatchActions()), 'per_page_choices' => $options['per_page_choices'], - 'device' => $dataTable->getDevice(), + 'responsive_breakpoints' => $options['responsive_breakpoints'], + 'active_breakpoint' => $dataTable->getActiveBreakpoint(), + 'breakpoint_resolver' => new BreakpointResolver($options['responsive_breakpoints']), ]); + $breakpointNames = array_keys($options['responsive_breakpoints']); + + foreach ($columns as $column) { + $visibleFrom = $column->getConfig()->getOption('visible_from'); + + if (is_string($visibleFrom) && !in_array($visibleFrom, $breakpointNames, true)) { + throw new InvalidArgumentException(sprintf( + 'Column "%s" has visible_from "%s", but the data table "%s" only defines breakpoints: %s.', + $column->getName(), + $visibleFrom, + $dataTable->getName(), + implode(', ', $breakpointNames), + )); + } + } + $view->headerRow = $this->createHeaderRowView($view, $dataTable, $visibleColumns); $view->nonPersonalizedHeaderRow = $this->createHeaderRowView($view, $dataTable, $columns); $view->valueRows = new RowIterator(fn () => $this->createValueRowsViews($view, $dataTable, $visibleColumns)); @@ -188,6 +207,12 @@ public function configureOptions(OptionsResolver $resolver): void 'personalization_form_factory' => $this->defaults['personalization']['form_factory'] ?? null, 'exporting_enabled' => $this->defaults['exporting']['enabled'] ?? false, 'exporting_form_factory' => $this->defaults['exporting']['form_factory'] ?? null, + 'responsive_breakpoints' => $this->defaults['responsive']['breakpoints'] ?? [ + 'sm' => 576, + 'md' => 768, + 'lg' => 992, + 'xl' => 1200, + ], ]) ->setAllowedTypes('title', ['null', 'string', TranslatableInterface::class]) ->setAllowedTypes('title_translation_parameters', ['array']) @@ -219,6 +244,7 @@ public function configureOptions(OptionsResolver $resolver): void ->setAllowedTypes('personalization_form_factory', ['null', FormFactoryInterface::class]) ->setAllowedTypes('exporting_enabled', 'bool') ->setAllowedTypes('exporting_form_factory', ['null', FormFactoryInterface::class]) + ->setAllowedTypes('responsive_breakpoints', 'array') ; } diff --git a/tests/Unit/Column/Type/ColumnTypeTest.php b/tests/Unit/Column/Type/ColumnTypeTest.php index 594818e0..5cdfd04a 100644 --- a/tests/Unit/Column/Type/ColumnTypeTest.php +++ b/tests/Unit/Column/Type/ColumnTypeTest.php @@ -9,7 +9,6 @@ use Kreyu\Bundle\DataTableBundle\Column\Type\ColumnTypeInterface; use Kreyu\Bundle\DataTableBundle\Column\Type\ResolvedColumnTypeInterface; use Kreyu\Bundle\DataTableBundle\DataTableView; -use Kreyu\Bundle\DataTableBundle\Responsive\Device; use Kreyu\Bundle\DataTableBundle\Sorting\SortingData; use Kreyu\Bundle\DataTableBundle\Test\Column\Type\ColumnTypeTestCase; use Kreyu\Bundle\DataTableBundle\Tests\Fixtures\Model\User; @@ -1059,19 +1058,19 @@ public function testBlockPrefixesWithParent() $this->assertEquals($expectedBlockPrefixes, $columnValueView->vars['block_prefixes']); } - public function testDefaultVisibleFromIsPhone(): void + public function testDefaultVisibleFromIsNull(): void { $column = $this->createNamedColumn('firstName'); $columnHeaderView = $this->createColumnHeaderView($column); $columnValueView = $this->createColumnValueView($column); - $this->assertSame(Device::Phone, $columnHeaderView->vars['visible_from']); - $this->assertSame(Device::Phone, $columnValueView->vars['visible_from']); + $this->assertNull($columnHeaderView->vars['visible_from']); + $this->assertNull($columnValueView->vars['visible_from']); } #[DataProvider('provideVisibleFromOptions')] - public function testPassingVisibleFromOption(Device|false $visibleFrom): void + public function testPassingVisibleFromOption(string|false|null $visibleFrom): void { $column = $this->createNamedColumn('firstName', [ 'visible_from' => $visibleFrom, @@ -1086,10 +1085,12 @@ public function testPassingVisibleFromOption(Device|false $visibleFrom): void public static function provideVisibleFromOptions(): iterable { - yield 'phone' => [Device::Phone]; - yield 'tablet' => [Device::Tablet]; - yield 'desktop' => [Device::Desktop]; - yield 'false' => [false]; + yield 'null (always visible)' => [null]; + yield 'sm' => ['sm']; + yield 'md' => ['md']; + yield 'lg' => ['lg']; + yield 'xl' => ['xl']; + yield 'false (always collapsed)' => [false]; } public function testVisibleFromRejectsTrueValue(): void diff --git a/tests/Unit/Responsive/BreakpointResolverTest.php b/tests/Unit/Responsive/BreakpointResolverTest.php new file mode 100644 index 00000000..d41e0391 --- /dev/null +++ b/tests/Unit/Responsive/BreakpointResolverTest.php @@ -0,0 +1,156 @@ +resolver = new BreakpointResolver([ + 'sm' => 576, + 'md' => 768, + 'lg' => 992, + 'xl' => 1200, + ]); + } + + #[DataProvider('provideResolveCases')] + public function testResolve(int $width, string $expected): void + { + $this->assertSame($expected, $this->resolver->resolve($width)); + } + + public static function provideResolveCases(): iterable + { + yield 'below sm' => [400, 'sm']; + yield 'at sm boundary' => [576, 'sm']; + yield 'between sm and md' => [700, 'md']; + yield 'at md boundary' => [768, 'md']; + yield 'between md and lg' => [900, 'lg']; + yield 'at lg boundary' => [992, 'lg']; + yield 'between lg and xl' => [1100, 'xl']; + yield 'at xl boundary' => [1200, 'xl']; + yield 'above xl' => [1920, 'xl']; + } + + #[DataProvider('provideIsVisibleCases')] + public function testIsVisible(string $activeBreakpoint, string $minimumBreakpoint, bool $expected): void + { + $this->assertSame($expected, $this->resolver->isVisible($activeBreakpoint, $minimumBreakpoint)); + } + + public static function provideIsVisibleCases(): iterable + { + // sm is the smallest breakpoint + yield 'sm >= sm' => ['sm', 'sm', true]; + yield 'sm >= md' => ['sm', 'md', false]; + yield 'sm >= lg' => ['sm', 'lg', false]; + yield 'sm >= xl' => ['sm', 'xl', false]; + + yield 'md >= sm' => ['md', 'sm', true]; + yield 'md >= md' => ['md', 'md', true]; + yield 'md >= lg' => ['md', 'lg', false]; + yield 'md >= xl' => ['md', 'xl', false]; + + yield 'lg >= sm' => ['lg', 'sm', true]; + yield 'lg >= md' => ['lg', 'md', true]; + yield 'lg >= lg' => ['lg', 'lg', true]; + yield 'lg >= xl' => ['lg', 'xl', false]; + + yield 'xl >= sm' => ['xl', 'sm', true]; + yield 'xl >= md' => ['xl', 'md', true]; + yield 'xl >= lg' => ['xl', 'lg', true]; + yield 'xl >= xl' => ['xl', 'xl', true]; + } + + public function testIsVisibleWithUnknownMinimumBreakpointReturnsTrue(): void + { + $this->assertTrue($this->resolver->isVisible('md', 'unknown')); + } + + #[DataProvider('provideUaFallbackCases')] + public function testResolveUaFallback(Device $device, ?string $expected): void + { + $this->assertSame($expected, $this->resolver->resolveUaFallback($device)); + } + + public static function provideUaFallbackCases(): iterable + { + // 4 breakpoints: sm, md, lg, xl → floor(4/2) = 2 → index 2 = 'lg' + yield 'phone → smallest (sm)' => [Device::Phone, 'sm']; + yield 'tablet → median upper (lg)' => [Device::Tablet, 'lg']; + yield 'desktop → largest (xl)' => [Device::Desktop, 'xl']; + } + + public function testResolveUaFallbackWithEvenNumberOfBreakpoints(): void + { + // 4 breakpoints: sm, md, lg, xl → floor(4/2) = 2 → 'lg' (upper of the two middle) + $this->assertSame('lg', $this->resolver->resolveUaFallback(Device::Tablet)); + } + + public function testResolveUaFallbackWithOddNumberOfBreakpoints(): void + { + $resolver = new BreakpointResolver([ + 'sm' => 576, + 'md' => 768, + 'lg' => 992, + ]); + + // 3 breakpoints: sm, md, lg → floor(3/2) = 1 → 'md' (true median) + $this->assertSame('md', $resolver->resolveUaFallback(Device::Tablet)); + } + + public function testResolveUaFallbackWithTwoBreakpoints(): void + { + $resolver = new BreakpointResolver([ + 'compact' => 600, + 'wide' => 1200, + ]); + + // 2 breakpoints: compact, wide → floor(2/2) = 1 → 'wide' (upper of the pair) + $this->assertSame('wide', $resolver->resolveUaFallback(Device::Tablet)); + } + + public function testResolveUaFallbackWithEmptyBreakpoints(): void + { + $resolver = new BreakpointResolver([]); + + $this->assertNull($resolver->resolveUaFallback(Device::Phone)); + $this->assertNull($resolver->resolveUaFallback(Device::Tablet)); + $this->assertNull($resolver->resolveUaFallback(Device::Desktop)); + } + + public function testGetBreakpoints(): void + { + $breakpoints = ['sm' => 576, 'md' => 768, 'lg' => 992, 'xl' => 1200]; + $resolver = new BreakpointResolver($breakpoints); + + $this->assertSame($breakpoints, $resolver->getBreakpoints()); + } + + public function testCustomBreakpointNames(): void + { + $resolver = new BreakpointResolver([ + 'compact' => 480, + 'normal' => 960, + 'wide' => 1440, + ]); + + $this->assertSame('compact', $resolver->resolve(300)); + $this->assertSame('normal', $resolver->resolve(700)); + $this->assertSame('wide', $resolver->resolve(1200)); + $this->assertSame('wide', $resolver->resolve(1920)); + + $this->assertTrue($resolver->isVisible('wide', 'compact')); + $this->assertFalse($resolver->isVisible('compact', 'wide')); + } +} diff --git a/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php b/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php index e462d141..d3f19cdd 100644 --- a/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php +++ b/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php @@ -14,7 +14,14 @@ class HttpFoundationRequestHandlerDeviceDetectionTest extends TestCase { - public function testDeviceParameterOverridesUserAgent(): void + private const DEFAULT_BREAKPOINTS = [ + 'sm' => 576, + 'md' => 768, + 'lg' => 992, + 'xl' => 1200, + ]; + + public function testBreakpointParameterOverridesUserAgent(): void { $detector = $this->createMock(DeviceDetectorInterface::class); $detector->expects($this->never())->method('detect'); @@ -23,13 +30,13 @@ public function testDeviceParameterOverridesUserAgent(): void $dataTable = $this->createDataTableMock(); $dataTable->expects($this->once()) - ->method('setDevice') - ->with(Device::Phone); + ->method('setActiveBreakpoint') + ->with('sm'); - $handler->handle($dataTable, $this->createRequest(['_device' => 'phone'])); + $handler->handle($dataTable, $this->createRequest(['_breakpoint' => 'sm'])); } - public function testFallsBackToUserAgentWhenNoDeviceParameter(): void + public function testFallsBackToUserAgentWhenNoBreakpointParameter(): void { $detector = $this->createMock(DeviceDetectorInterface::class); $detector->expects($this->once()) @@ -40,63 +47,66 @@ public function testFallsBackToUserAgentWhenNoDeviceParameter(): void $dataTable = $this->createDataTableMock(); $dataTable->expects($this->once()) - ->method('setDevice') - ->with(Device::Tablet); + ->method('setActiveBreakpoint') + ->with('lg'); // Tablet → median upper breakpoint (lg for 4 breakpoints) $handler->handle($dataTable, $this->createRequest()); } - public function testFallsBackToUserAgentWhenDeviceParameterIsInvalid(): void + public function testFallsBackToUserAgentPhoneGivesSmallestBreakpoint(): void { $detector = $this->createMock(DeviceDetectorInterface::class); $detector->expects($this->once()) ->method('detect') - ->willReturn(Device::Desktop); + ->willReturn(Device::Phone); $handler = new HttpFoundationRequestHandler($detector); $dataTable = $this->createDataTableMock(); $dataTable->expects($this->once()) - ->method('setDevice') - ->with(Device::Desktop); + ->method('setActiveBreakpoint') + ->with('sm'); - $handler->handle($dataTable, $this->createRequest(['_device' => 'invalid'])); + $handler->handle($dataTable, $this->createRequest()); } - public function testNoDeviceDetectionWithoutDetector(): void + public function testFallsBackToUserAgentDesktopGivesLargestBreakpoint(): void { - $handler = new HttpFoundationRequestHandler(); + $detector = $this->createMock(DeviceDetectorInterface::class); + $detector->expects($this->once()) + ->method('detect') + ->willReturn(Device::Desktop); + + $handler = new HttpFoundationRequestHandler($detector); $dataTable = $this->createDataTableMock(); - $dataTable->expects($this->never())->method('setDevice'); + $dataTable->expects($this->once()) + ->method('setActiveBreakpoint') + ->with('xl'); // Desktop → largest breakpoint $handler->handle($dataTable, $this->createRequest()); } - public function testDeviceParameterWorksWithoutDetector(): void + public function testNoDeviceDetectionWithoutDetector(): void { $handler = new HttpFoundationRequestHandler(); $dataTable = $this->createDataTableMock(); - $dataTable->expects($this->once()) - ->method('setDevice') - ->with(Device::Tablet); + $dataTable->expects($this->never())->method('setActiveBreakpoint'); - $handler->handle($dataTable, $this->createRequest(['_device' => 'tablet'])); + $handler->handle($dataTable, $this->createRequest()); } - public function testAllDeviceParameterValues(): void + public function testBreakpointParameterWorksWithoutDetector(): void { $handler = new HttpFoundationRequestHandler(); - foreach (Device::cases() as $device) { - $dataTable = $this->createDataTableMock(); - $dataTable->expects($this->once()) - ->method('setDevice') - ->with($device); + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setActiveBreakpoint') + ->with('md'); - $handler->handle($dataTable, $this->createRequest(['_device' => $device->value])); - } + $handler->handle($dataTable, $this->createRequest(['_breakpoint' => 'md'])); } public function testNullRequestDoesNothing(): void @@ -104,7 +114,7 @@ public function testNullRequestDoesNothing(): void $handler = new HttpFoundationRequestHandler(); $dataTable = $this->createDataTableMock(); - $dataTable->expects($this->never())->method('setDevice'); + $dataTable->expects($this->never())->method('setActiveBreakpoint'); $handler->handle($dataTable, null); } @@ -125,6 +135,13 @@ private function createDataTableMock(): DataTableInterface&\PHPUnit\Framework\Mo $config->method('isPaginationEnabled')->willReturn(false); $config->method('isPersonalizationEnabled')->willReturn(false); $config->method('isExportingEnabled')->willReturn(false); + $config->method('getOption') + ->willReturnCallback(function (string $name) { + if ($name === 'responsive_breakpoints') { + return self::DEFAULT_BREAKPOINTS; + } + return null; + }); $dataTable = $this->createMock(DataTableInterface::class); $dataTable->method('getConfig')->willReturn($config); From e6ed1873bc8ad7a78ef30d9b0404a852d43439a7 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 16 Apr 2026 15:37:05 +0200 Subject: [PATCH 3/5] Harden responsive: validate breakpoints, auto-sort, add responsive_enabled option --- assets/controllers/responsive.js | 3 +- src/Request/HttpFoundationRequestHandler.php | 14 ++++--- .../views/themes/_responsive.html.twig | 26 +++--------- src/Responsive/BreakpointResolver.php | 27 ++++++++---- src/Responsive/Device.php | 21 ---------- src/Type/DataTableType.php | 41 +++++++++++++------ .../Responsive/BreakpointResolverTest.php | 37 ++++++++++++++++- tests/Unit/Responsive/DeviceTest.php | 29 ------------- ...ationRequestHandlerDeviceDetectionTest.php | 17 ++++++++ 9 files changed, 114 insertions(+), 101 deletions(-) diff --git a/assets/controllers/responsive.js b/assets/controllers/responsive.js index cde35a6e..7872fbc9 100644 --- a/assets/controllers/responsive.js +++ b/assets/controllers/responsive.js @@ -6,7 +6,6 @@ export default class extends Controller { static values = { breakpoints: { type: Object, default: {} }, - parameterName: { type: String, default: '_breakpoint' }, currentBreakpoint: { type: String, default: '' }, } @@ -117,7 +116,7 @@ export default class extends Controller { const baseUrl = frame.getAttribute('src') || window.location.href const url = new URL(baseUrl, window.location.origin) - url.searchParams.set(this.parameterNameValue, breakpoint) + url.searchParams.set('_breakpoint', breakpoint) frame.src = url.toString() } diff --git a/src/Request/HttpFoundationRequestHandler.php b/src/Request/HttpFoundationRequestHandler.php index e7ddc18f..c510bafc 100755 --- a/src/Request/HttpFoundationRequestHandler.php +++ b/src/Request/HttpFoundationRequestHandler.php @@ -155,19 +155,23 @@ private function extractQueryParameter(Request $request, string $path): mixed private function detectDevice(DataTableInterface $dataTable, Request $request): void { + $breakpoints = $dataTable->getConfig()->getOption('responsive_breakpoints') ?? []; + + if ([] === $breakpoints) { + return; + } + + $resolver = new BreakpointResolver($breakpoints); $breakpoint = $request->query->get('_breakpoint'); - if (null !== $breakpoint) { + if (null !== $breakpoint && $resolver->has($breakpoint)) { $dataTable->setActiveBreakpoint($breakpoint); return; } if (null !== $this->deviceDetector) { - $device = $this->deviceDetector->detect($request); - $breakpoints = $dataTable->getConfig()->getOption('responsive_breakpoints') ?? []; - $resolver = new BreakpointResolver($breakpoints); - $dataTable->setActiveBreakpoint($resolver->resolveUaFallback($device)); + $dataTable->setActiveBreakpoint($resolver->resolveUaFallback($this->deviceDetector->detect($request))); } } diff --git a/src/Resources/views/themes/_responsive.html.twig b/src/Resources/views/themes/_responsive.html.twig index 60658ce0..b433964a 100644 --- a/src/Resources/views/themes/_responsive.html.twig +++ b/src/Resources/views/themes/_responsive.html.twig @@ -34,24 +34,12 @@ {% block table_head_row %} - {% set has_hidden_columns = false %} - - {% for column_header in header_row %} - {% set col_visible_from = column_header.vars.visible_from %} - {% if col_visible_from is same as(false) %} - {% set has_hidden_columns = true %} - {% elseif col_visible_from is not null and active_breakpoint is not null and not breakpoint_resolver.isVisible(active_breakpoint, col_visible_from) %} - {% set has_hidden_columns = true %} - {% endif %} - {% endfor %} - - {% if has_hidden_columns %} + {% if responsive_hidden_columns|default([])|length > 0 %} {{ block('responsive_toggle_header', theme) }} {% endif %} {% for column_header in header_row %} - {% set col_visible_from = column_header.vars.visible_from %} - {% if col_visible_from is not same as(false) and (col_visible_from is null or active_breakpoint is null or breakpoint_resolver.isVisible(active_breakpoint, col_visible_from)) %} + {% if column_header.vars.name not in responsive_hidden_columns|default([]) %} {{- data_table_column_header(column_header) -}} {% endif %} {% endfor %} @@ -61,12 +49,10 @@ {% block table_body_value_row %} {% set visible_columns = [] %} {% set hidden_columns = [] %} + {% set row_index = value_row.vars.row.index|default(loop.index0) %} {% for column_name, column_value in value_row %} - {% set col_visible_from = column_value.vars.visible_from %} - {% if col_visible_from is same as(false) %} - {% set hidden_columns = hidden_columns|merge({ (column_name): column_value }) %} - {% elseif col_visible_from is not null and active_breakpoint is not null and not breakpoint_resolver.isVisible(active_breakpoint, col_visible_from) %} + {% if column_name in responsive_hidden_columns|default([]) %} {% set hidden_columns = hidden_columns|merge({ (column_name): column_value }) %} {% else %} {% set visible_columns = visible_columns|merge({ (column_name): column_value }) %} @@ -99,7 +85,7 @@ class="kreyu-dt-toggle-btn" aria-expanded="false" aria-label="{{ 'Toggle details'|trans }}" - data-row-index="{{ loop.index }}" + data-row-index="{{ row_index }}" data-action="click->kreyu--data-table-bundle--responsive#toggle" data-kreyu--data-table-bundle--responsive-target="toggleButton" > @@ -112,7 +98,7 @@ diff --git a/src/Responsive/BreakpointResolver.php b/src/Responsive/BreakpointResolver.php index 4c36825c..eaf6c0c1 100644 --- a/src/Responsive/BreakpointResolver.php +++ b/src/Responsive/BreakpointResolver.php @@ -6,12 +6,16 @@ class BreakpointResolver { + /** @var array */ + private readonly array $breakpoints; + /** - * @param array $breakpoints Associative array of name => max width, sorted ascending + * @param array $breakpoints Associative array of name => max width */ - public function __construct( - private readonly array $breakpoints, - ) { + public function __construct(array $breakpoints) + { + asort($breakpoints); + $this->breakpoints = $breakpoints; } /** @@ -32,9 +36,6 @@ public function resolve(int $width): string /** * Checks whether a column is visible at the given active breakpoint. - * - * A column with $minimumBreakpoint is visible when the active breakpoint - * is at the same position or higher in the configured breakpoints order. */ public function isVisible(string $activeBreakpoint, string $minimumBreakpoint): bool { @@ -42,13 +43,21 @@ public function isVisible(string $activeBreakpoint, string $minimumBreakpoint): $activeIndex = array_search($activeBreakpoint, $names, true); $minimumIndex = array_search($minimumBreakpoint, $names, true); - if (false === $minimumIndex) { - return true; + if (false === $activeIndex || false === $minimumIndex) { + return false; } return $activeIndex >= $minimumIndex; } + /** + * Returns whether the given breakpoint name exists in the configuration. + */ + public function has(string $name): bool + { + return isset($this->breakpoints[$name]); + } + /** * @return array */ diff --git a/src/Responsive/Device.php b/src/Responsive/Device.php index 707ebace..486f290d 100644 --- a/src/Responsive/Device.php +++ b/src/Responsive/Device.php @@ -9,25 +9,4 @@ enum Device: string case Phone = 'phone'; case Tablet = 'tablet'; case Desktop = 'desktop'; - - /** - * Returns true if this device is at least as large as $minimum. - * - * Cascade: Phone < Tablet < Desktop - * - * For example, if $minimum is Tablet, then Tablet and Desktop return true, but Phone returns false. - */ - public function isAtLeast(self $minimum): bool - { - return $this->order() >= $minimum->order(); - } - - private function order(): int - { - return match ($this) { - self::Phone => 0, - self::Tablet => 1, - self::Desktop => 2, - }; - } } diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index b2b1a5d3..bc8b6408 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -109,25 +109,38 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar 'sorting_clearable' => $dataTable->getConfig()->isSortingClearable(), 'has_batch_actions' => !empty($dataTable->getBatchActions()), 'per_page_choices' => $options['per_page_choices'], + 'responsive_enabled' => $options['responsive_enabled'], 'responsive_breakpoints' => $options['responsive_breakpoints'], 'active_breakpoint' => $dataTable->getActiveBreakpoint(), - 'breakpoint_resolver' => new BreakpointResolver($options['responsive_breakpoints']), + 'responsive_hidden_columns' => [], ]); - $breakpointNames = array_keys($options['responsive_breakpoints']); + if ($options['responsive_enabled']) { + $resolver = new BreakpointResolver($options['responsive_breakpoints']); + $activeBreakpoint = $dataTable->getActiveBreakpoint(); + $hiddenColumns = []; - foreach ($columns as $column) { - $visibleFrom = $column->getConfig()->getOption('visible_from'); - - if (is_string($visibleFrom) && !in_array($visibleFrom, $breakpointNames, true)) { - throw new InvalidArgumentException(sprintf( - 'Column "%s" has visible_from "%s", but the data table "%s" only defines breakpoints: %s.', - $column->getName(), - $visibleFrom, - $dataTable->getName(), - implode(', ', $breakpointNames), - )); + foreach ($columns as $column) { + $visibleFrom = $column->getConfig()->getOption('visible_from'); + + if (is_string($visibleFrom) && !$resolver->has($visibleFrom)) { + throw new InvalidArgumentException(sprintf( + 'Column "%s" has visible_from "%s", but the data table "%s" only defines breakpoints: %s.', + $column->getName(), + $visibleFrom, + $dataTable->getName(), + implode(', ', array_keys($resolver->getBreakpoints())), + )); + } + + if (false === $visibleFrom) { + $hiddenColumns[] = $column->getName(); + } elseif (is_string($visibleFrom) && null !== $activeBreakpoint && !$resolver->isVisible($activeBreakpoint, $visibleFrom)) { + $hiddenColumns[] = $column->getName(); + } } + + $view->vars['responsive_hidden_columns'] = $hiddenColumns; } $view->headerRow = $this->createHeaderRowView($view, $dataTable, $visibleColumns); @@ -207,6 +220,7 @@ public function configureOptions(OptionsResolver $resolver): void 'personalization_form_factory' => $this->defaults['personalization']['form_factory'] ?? null, 'exporting_enabled' => $this->defaults['exporting']['enabled'] ?? false, 'exporting_form_factory' => $this->defaults['exporting']['form_factory'] ?? null, + 'responsive_enabled' => $this->defaults['responsive']['enabled'] ?? false, 'responsive_breakpoints' => $this->defaults['responsive']['breakpoints'] ?? [ 'sm' => 576, 'md' => 768, @@ -244,6 +258,7 @@ public function configureOptions(OptionsResolver $resolver): void ->setAllowedTypes('personalization_form_factory', ['null', FormFactoryInterface::class]) ->setAllowedTypes('exporting_enabled', 'bool') ->setAllowedTypes('exporting_form_factory', ['null', FormFactoryInterface::class]) + ->setAllowedTypes('responsive_enabled', 'bool') ->setAllowedTypes('responsive_breakpoints', 'array') ; } diff --git a/tests/Unit/Responsive/BreakpointResolverTest.php b/tests/Unit/Responsive/BreakpointResolverTest.php index d41e0391..a16ab717 100644 --- a/tests/Unit/Responsive/BreakpointResolverTest.php +++ b/tests/Unit/Responsive/BreakpointResolverTest.php @@ -72,9 +72,42 @@ public static function provideIsVisibleCases(): iterable yield 'xl >= xl' => ['xl', 'xl', true]; } - public function testIsVisibleWithUnknownMinimumBreakpointReturnsTrue(): void + public function testIsVisibleWithUnknownMinimumBreakpointReturnsFalse(): void { - $this->assertTrue($this->resolver->isVisible('md', 'unknown')); + $this->assertFalse($this->resolver->isVisible('md', 'unknown')); + } + + public function testIsVisibleWithUnknownActiveBreakpointReturnsFalse(): void + { + $this->assertFalse($this->resolver->isVisible('unknown', 'md')); + } + + public function testBreakpointsAreSortedAutomatically(): void + { + $resolver = new BreakpointResolver([ + 'xl' => 1200, + 'sm' => 576, + 'lg' => 992, + 'md' => 768, + ]); + + // Despite unordered input, resolve should work correctly + $this->assertSame('sm', $resolver->resolve(400)); + $this->assertSame('md', $resolver->resolve(700)); + $this->assertSame('lg', $resolver->resolve(900)); + $this->assertSame('xl', $resolver->resolve(1100)); + + // isVisible should respect the sorted order + $this->assertTrue($resolver->isVisible('lg', 'sm')); + $this->assertFalse($resolver->isVisible('sm', 'lg')); + } + + public function testHasBreakpoint(): void + { + $this->assertTrue($this->resolver->has('sm')); + $this->assertTrue($this->resolver->has('xl')); + $this->assertFalse($this->resolver->has('unknown')); + $this->assertFalse($this->resolver->has('')); } #[DataProvider('provideUaFallbackCases')] diff --git a/tests/Unit/Responsive/DeviceTest.php b/tests/Unit/Responsive/DeviceTest.php index 9e30b110..2277949f 100644 --- a/tests/Unit/Responsive/DeviceTest.php +++ b/tests/Unit/Responsive/DeviceTest.php @@ -5,39 +5,10 @@ namespace Kreyu\Bundle\DataTableBundle\Tests\Unit\Responsive; use Kreyu\Bundle\DataTableBundle\Responsive\Device; -use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; class DeviceTest extends TestCase { - #[DataProvider('provideIsAtLeastCases')] - public function testIsAtLeast(Device $device, Device $minimum, bool $expected): void - { - $this->assertSame($expected, $device->isAtLeast($minimum)); - } - - public static function provideIsAtLeastCases(): iterable - { - // Phone is at least Phone - yield 'phone >= phone' => [Device::Phone, Device::Phone, true]; - // Phone is NOT at least Tablet - yield 'phone >= tablet' => [Device::Phone, Device::Tablet, false]; - // Phone is NOT at least Desktop - yield 'phone >= desktop' => [Device::Phone, Device::Desktop, false]; - - // Tablet is at least Phone - yield 'tablet >= phone' => [Device::Tablet, Device::Phone, true]; - // Tablet is at least Tablet - yield 'tablet >= tablet' => [Device::Tablet, Device::Tablet, true]; - // Tablet is NOT at least Desktop - yield 'tablet >= desktop' => [Device::Tablet, Device::Desktop, false]; - - // Desktop is at least everything - yield 'desktop >= phone' => [Device::Desktop, Device::Phone, true]; - yield 'desktop >= tablet' => [Device::Desktop, Device::Tablet, true]; - yield 'desktop >= desktop' => [Device::Desktop, Device::Desktop, true]; - } - public function testStringValues(): void { $this->assertSame('phone', Device::Phone->value); diff --git a/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php b/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php index d3f19cdd..3febbf39 100644 --- a/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php +++ b/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php @@ -87,6 +87,23 @@ public function testFallsBackToUserAgentDesktopGivesLargestBreakpoint(): void $handler->handle($dataTable, $this->createRequest()); } + public function testInvalidBreakpointParameterFallsBackToUserAgent(): void + { + $detector = $this->createMock(DeviceDetectorInterface::class); + $detector->expects($this->once()) + ->method('detect') + ->willReturn(Device::Desktop); + + $handler = new HttpFoundationRequestHandler($detector); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setActiveBreakpoint') + ->with('xl'); // Falls back to UA (Desktop → largest) + + $handler->handle($dataTable, $this->createRequest(['_breakpoint' => 'invalid'])); + } + public function testNoDeviceDetectionWithoutDetector(): void { $handler = new HttpFoundationRequestHandler(); From f3d3653c65b19ca79568ac95cc6fac8abe8f7fc2 Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Thu, 16 Apr 2026 16:26:09 +0200 Subject: [PATCH 4/5] Disable responsive blocks when responsive_enabled is false --- assets/controllers/responsive.js | 1 + assets/styles/responsive.css | 4 ++ .../KreyuDataTableExtension.php | 1 + src/Request/HttpFoundationRequestHandler.php | 5 +-- .../views/themes/_responsive.html.twig | 13 +++--- .../themes/bootstrap_5_responsive.html.twig | 12 +++++- .../views/themes/tabler_responsive.html.twig | 12 +++++- src/Type/DataTableType.php | 14 +++++++ ...ationRequestHandlerDeviceDetectionTest.php | 9 ++-- .../Unit/Twig/ResponsiveThemeFallbackTest.php | 42 +++++++++++++++++++ 10 files changed, 99 insertions(+), 14 deletions(-) create mode 100644 tests/Unit/Twig/ResponsiveThemeFallbackTest.php diff --git a/assets/controllers/responsive.js b/assets/controllers/responsive.js index 7872fbc9..ae67b335 100644 --- a/assets/controllers/responsive.js +++ b/assets/controllers/responsive.js @@ -1,4 +1,5 @@ import { Controller } from '@hotwired/stimulus' +import '../styles/responsive.css' /* stimulusFetch: 'eager' */ export default class extends Controller { diff --git a/assets/styles/responsive.css b/assets/styles/responsive.css index 63abae5d..ee79bb03 100644 --- a/assets/styles/responsive.css +++ b/assets/styles/responsive.css @@ -1,3 +1,7 @@ +.kreyu-dt-responsive-pending { + visibility: hidden; +} + .kreyu-dt-collapsible-row td { padding: 0 !important; } diff --git a/src/DependencyInjection/KreyuDataTableExtension.php b/src/DependencyInjection/KreyuDataTableExtension.php index b24b9b7e..1fdfe37c 100755 --- a/src/DependencyInjection/KreyuDataTableExtension.php +++ b/src/DependencyInjection/KreyuDataTableExtension.php @@ -113,6 +113,7 @@ public function prepend(ContainerBuilder $container): void 'asset_mapper' => [ 'paths' => [ __DIR__.'/../../assets/controllers' => '@kreyu/data-table-bundle', + __DIR__.'/../../assets/styles' => '@kreyu/data-table-bundle-styles', ], ], ]); diff --git a/src/Request/HttpFoundationRequestHandler.php b/src/Request/HttpFoundationRequestHandler.php index c510bafc..50a92669 100755 --- a/src/Request/HttpFoundationRequestHandler.php +++ b/src/Request/HttpFoundationRequestHandler.php @@ -155,12 +155,11 @@ private function extractQueryParameter(Request $request, string $path): mixed private function detectDevice(DataTableInterface $dataTable, Request $request): void { - $breakpoints = $dataTable->getConfig()->getOption('responsive_breakpoints') ?? []; - - if ([] === $breakpoints) { + if (!($dataTable->getConfig()->getOption('responsive_enabled') ?? false)) { return; } + $breakpoints = $dataTable->getConfig()->getOption('responsive_breakpoints') ?? []; $resolver = new BreakpointResolver($breakpoints); $breakpoint = $request->query->get('_breakpoint'); diff --git a/src/Resources/views/themes/_responsive.html.twig b/src/Resources/views/themes/_responsive.html.twig index b433964a..f874acff 100644 --- a/src/Resources/views/themes/_responsive.html.twig +++ b/src/Resources/views/themes/_responsive.html.twig @@ -4,17 +4,18 @@ {# Provides collapsible rows for columns hidden at the current breakpoint #} {% block kreyu_data_table %} - {% set stimulus_controllers = ['kreyu--data-table-bundle--state', 'kreyu--data-table-bundle--responsive'] %} + {% set stimulus_controllers = ['kreyu--data-table-bundle--state'] %} + + {% if responsive_enabled|default(false) %} + {% set stimulus_controllers = stimulus_controllers|merge(['kreyu--data-table-bundle--responsive']) %} + {% endif %} {% if has_batch_actions %} {% set stimulus_controllers = stimulus_controllers|merge(['kreyu--data-table-bundle--batch']) %} {% endif %} - -
+ {{ block('action_bar', theme) }} {{ block('table', theme) }} @@ -28,8 +29,10 @@ {% block kreyu_data_table_attributes %} data-controller="{{ stimulus_controllers|join(' ') }}" data-kreyu--data-table-bundle--state-url-query-parameters-value="{{ url_query_parameters|default({})|json_encode(constant('JSON_FORCE_OBJECT')) }}" + {% if responsive_enabled|default(false) %} data-kreyu--data-table-bundle--responsive-breakpoints-value="{{ responsive_breakpoints|json_encode }}" data-kreyu--data-table-bundle--responsive-current-breakpoint-value="{{ active_breakpoint|default('') }}" + {% endif %} {% endblock %} {% block table_head_row %} diff --git a/src/Resources/views/themes/bootstrap_5_responsive.html.twig b/src/Resources/views/themes/bootstrap_5_responsive.html.twig index d2195302..80d3b8fb 100644 --- a/src/Resources/views/themes/bootstrap_5_responsive.html.twig +++ b/src/Resources/views/themes/bootstrap_5_responsive.html.twig @@ -1,2 +1,12 @@ {% extends '@KreyuDataTable/themes/bootstrap_5.html.twig' %} -{% use '@KreyuDataTable/themes/_responsive.html.twig' %} +{% use '@KreyuDataTable/themes/_responsive.html.twig' with + kreyu_data_table as _responsive_kreyu_data_table, + kreyu_data_table_attributes as _responsive_kreyu_data_table_attributes, + table_head_row as _responsive_table_head_row, + table_body_value_row as _responsive_table_body_value_row +%} + +{% block kreyu_data_table %}{{ responsive_enabled|default(false) ? block('_responsive_kreyu_data_table') : parent() }}{% endblock %} +{% block kreyu_data_table_attributes %}{{ responsive_enabled|default(false) ? block('_responsive_kreyu_data_table_attributes') : parent() }}{% endblock %} +{% block table_head_row %}{{ responsive_enabled|default(false) ? block('_responsive_table_head_row') : parent() }}{% endblock %} +{% block table_body_value_row %}{{ responsive_enabled|default(false) ? block('_responsive_table_body_value_row') : parent() }}{% endblock %} diff --git a/src/Resources/views/themes/tabler_responsive.html.twig b/src/Resources/views/themes/tabler_responsive.html.twig index c2cf7a0c..60e75c0b 100644 --- a/src/Resources/views/themes/tabler_responsive.html.twig +++ b/src/Resources/views/themes/tabler_responsive.html.twig @@ -1,2 +1,12 @@ {% extends '@KreyuDataTable/themes/tabler.html.twig' %} -{% use '@KreyuDataTable/themes/_responsive.html.twig' %} +{% use '@KreyuDataTable/themes/_responsive.html.twig' with + kreyu_data_table as _responsive_kreyu_data_table, + kreyu_data_table_attributes as _responsive_kreyu_data_table_attributes, + table_head_row as _responsive_table_head_row, + table_body_value_row as _responsive_table_body_value_row +%} + +{% block kreyu_data_table %}{{ responsive_enabled|default(false) ? block('_responsive_kreyu_data_table') : parent() }}{% endblock %} +{% block kreyu_data_table_attributes %}{{ responsive_enabled|default(false) ? block('_responsive_kreyu_data_table_attributes') : parent() }}{% endblock %} +{% block table_head_row %}{{ responsive_enabled|default(false) ? block('_responsive_table_head_row') : parent() }}{% endblock %} +{% block table_body_value_row %}{{ responsive_enabled|default(false) ? block('_responsive_table_body_value_row') : parent() }}{% endblock %} diff --git a/src/Type/DataTableType.php b/src/Type/DataTableType.php index bc8b6408..db0a4a55 100755 --- a/src/Type/DataTableType.php +++ b/src/Type/DataTableType.php @@ -141,6 +141,20 @@ public function buildView(DataTableView $view, DataTableInterface $dataTable, ar } $view->vars['responsive_hidden_columns'] = $hiddenColumns; + } else { + foreach ($columns as $column) { + $visibleFrom = $column->getConfig()->getOption('visible_from'); + + if (null !== $visibleFrom) { + throw new InvalidArgumentException(sprintf( + 'Column "%s" has visible_from set to "%s", but the data table "%s" has responsive disabled. ' + . 'Either enable the responsive feature or remove the visible_from option from this column.', + $column->getName(), + is_string($visibleFrom) ? $visibleFrom : var_export($visibleFrom, true), + $dataTable->getName(), + )); + } + } } $view->headerRow = $this->createHeaderRowView($view, $dataTable, $visibleColumns); diff --git a/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php b/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php index 3febbf39..e6d45f41 100644 --- a/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php +++ b/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php @@ -154,10 +154,11 @@ private function createDataTableMock(): DataTableInterface&\PHPUnit\Framework\Mo $config->method('isExportingEnabled')->willReturn(false); $config->method('getOption') ->willReturnCallback(function (string $name) { - if ($name === 'responsive_breakpoints') { - return self::DEFAULT_BREAKPOINTS; - } - return null; + return match ($name) { + 'responsive_enabled' => true, + 'responsive_breakpoints' => self::DEFAULT_BREAKPOINTS, + default => null, + }; }); $dataTable = $this->createMock(DataTableInterface::class); diff --git a/tests/Unit/Twig/ResponsiveThemeFallbackTest.php b/tests/Unit/Twig/ResponsiveThemeFallbackTest.php new file mode 100644 index 00000000..9818d34b --- /dev/null +++ b/tests/Unit/Twig/ResponsiveThemeFallbackTest.php @@ -0,0 +1,42 @@ + '{% block table_head_row %}base_head_row{% endblock %}', + '_responsive.html.twig' => '{% block table_head_row %}responsive_head_row{% endblock %}', + 'responsive_theme.html.twig' => <<<'TWIG' + {% extends 'base.html.twig' %} + {% use '_responsive.html.twig' with table_head_row as _responsive_table_head_row %} + {% block table_head_row %}{{ responsive_enabled|default(false) ? block('_responsive_table_head_row') : parent() }}{% endblock %} + TWIG, + ])); + + $template = $environment->load('responsive_theme.html.twig'); + + $disabledHtml = $template->renderBlock('table_head_row', ['responsive_enabled' => false]); + $this->assertStringContainsString('base_head_row', $disabledHtml); + $this->assertStringNotContainsString('responsive_head_row', $disabledHtml); + + $enabledHtml = $template->renderBlock('table_head_row', ['responsive_enabled' => true]); + $this->assertStringContainsString('responsive_head_row', $enabledHtml); + $this->assertStringNotContainsString('base_head_row', $enabledHtml); + } +} From b387121e9b19d0478914151ad1472e6c294331fb Mon Sep 17 00:00:00 2001 From: Alexandre Castelain Date: Fri, 17 Apr 2026 16:57:32 +0200 Subject: [PATCH 5/5] Add docs --- docs/.vitepress/config.mts | 1 + docs/src/docs/features/responsive.md | 333 +++++++++++++++++++++++++++ 2 files changed, 334 insertions(+) create mode 100644 docs/src/docs/features/responsive.md diff --git a/docs/.vitepress/config.mts b/docs/.vitepress/config.mts index a8f5a713..d2654efd 100644 --- a/docs/.vitepress/config.mts +++ b/docs/.vitepress/config.mts @@ -50,6 +50,7 @@ export default defineConfig({ { text: 'Exporting', link: '/docs/features/exporting' }, { text: 'Pagination', link: '/docs/features/pagination' }, { text: 'Personalization', link: '/docs/features/personalization' }, + { text: 'Responsive', link: '/docs/features/responsive' }, { text: 'Persistence', link: '/docs/features/persistence' }, { text: 'Theming', link: '/docs/features/theming' }, { text: 'Asynchronicity', link: '/docs/features/asynchronicity' }, diff --git a/docs/src/docs/features/responsive.md b/docs/src/docs/features/responsive.md new file mode 100644 index 00000000..45b1aeca --- /dev/null +++ b/docs/src/docs/features/responsive.md @@ -0,0 +1,333 @@ +# Responsive + +The data tables can adapt to narrow viewports by hiding columns below a configurable breakpoint and exposing them through a **collapsible row** per data row. This is useful for tables with many columns that would otherwise overflow on phones and tablets. + +[[toc]] + +## Prerequisites + +To begin with, make sure the [Symfony UX integration is enabled](../installation.md#enable-the-symfony-ux-integration). +The **responsive** controller is enabled by default. If you have explicitly disabled it in your `assets/controllers.json`, re-enable it: + +```json +{ + "controllers": { + "@kreyu/data-table-bundle": { + "responsive": { + "enabled": true + } + } + } +} +``` + +Additionally, the responsive feature relies on [Turbo](https://turbo.hotwired.dev/) to reload the data table frame when the active breakpoint changes on resize. Turbo is also a requirement of the [asynchronicity](asynchronicity.md) feature. + +## Toggling the feature + +By default, the responsive feature is **disabled** for every data table. + +You can change this setting globally using the package configuration file, or use the `responsive_enabled` option: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + responsive: + enabled: true +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $config->responsive()->enabled(true); +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'responsive_enabled' => true, + ]); + } +} +``` + +```php [For specific data table] +use App\DataTable\Type\ProductDataTableType; +use Kreyu\Bundle\DataTableBundle\DataTableFactoryAwareTrait; +use Symfony\Bundle\FrameworkBundle\Controller\AbstractController; + +class ProductController extends AbstractController +{ + use DataTableFactoryAwareTrait; + + public function index() + { + $dataTable = $this->createDataTable( + type: ProductDataTableType::class, + query: $query, + options: [ + 'responsive_enabled' => true, + ], + ); + } +} +``` +::: + +::: warning The `responsive` configuration lives at the root level +Unlike most other features, responsive options are defined at `kreyu_data_table.responsive.*`, **not** under `kreyu_data_table.defaults.*`. Breakpoints are global by design and shared across every data table type that opts into responsiveness. +::: + +## Configuring breakpoints + +Breakpoints are declared as an associative array of `name => max width in pixels`. A column associated with a breakpoint remains visible as long as the resolved active breakpoint is greater than or equal to it. + +The default breakpoints follow Bootstrap conventions: + +| Name | Max width (px) | +|------|----------------| +| `sm` | 576 | +| `md` | 768 | +| `lg` | 992 | +| `xl` | 1200 | + +You can override them globally, or per data table using the `responsive_breakpoints` option: + +::: code-group +```yaml [Globally (YAML)] +kreyu_data_table: + responsive: + enabled: true + breakpoints: + sm: 576 + md: 768 + lg: 992 + xl: 1200 + xxl: 1400 +``` + +```php [Globally (PHP)] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $config->responsive() + ->enabled(true) + ->breakpoints([ + 'sm' => 576, + 'md' => 768, + 'lg' => 992, + 'xl' => 1200, + 'xxl' => 1400, + ]) + ; +}; +``` + +```php [For data table type] +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; +use Symfony\Component\OptionsResolver\OptionsResolver; + +class ProductDataTableType extends AbstractDataTableType +{ + public function configureOptions(OptionsResolver $resolver): void + { + $resolver->setDefaults([ + 'responsive_enabled' => true, + 'responsive_breakpoints' => [ + 'sm' => 480, + 'md' => 720, + 'lg' => 1024, + ], + ]); + } +} +``` +::: + +::: tip Order does not matter +Breakpoints are automatically sorted from the smallest to the largest value at runtime, so you can declare them in any order. +::: + +## Controlling column visibility + +Each column can opt into being hidden below a minimum breakpoint using the `visible_from` option. + +| Value | Behavior | +|--------------------|-----------------------------------------------------------------------------| +| `null` *(default)* | The column is always visible, regardless of the active breakpoint. | +| `'sm'`, `'md'`, … | The column is visible only when the active breakpoint is `>= visible_from`. | +| `false` | The column is always moved into the collapsible row. | + +```php src/DataTable/Type/ProductDataTableType.php +use Kreyu\Bundle\DataTableBundle\Column\Type\ActionsColumnType; +use Kreyu\Bundle\DataTableBundle\Column\Type\NumberColumnType; +use Kreyu\Bundle\DataTableBundle\Column\Type\TextColumnType; +use Kreyu\Bundle\DataTableBundle\DataTableBuilderInterface; +use Kreyu\Bundle\DataTableBundle\Type\AbstractDataTableType; + +class ProductDataTableType extends AbstractDataTableType +{ + public function buildDataTable(DataTableBuilderInterface $builder, array $options): void + { + $builder + ->addColumn('id', NumberColumnType::class, [ + // Always visible. + ]) + ->addColumn('name', TextColumnType::class, [ + // Always visible. + ]) + ->addColumn('description', TextColumnType::class, [ + // Hidden below the `lg` breakpoint. + 'visible_from' => 'lg', + ]) + ->addColumn('actions', ActionsColumnType::class, [ + // Always in the collapsible row, regardless of viewport width. + 'visible_from' => false, + ]) + ; + } +} +``` + +::: warning `visible_from` requires responsive mode +Setting `visible_from` to anything other than `null` on a data table that is not responsive-enabled (or referencing an unknown breakpoint name) throws an `InvalidArgumentException` at build time. Always enable `responsive_enabled` before using `visible_from`, and make sure any string value matches a declared breakpoint. +::: + +## Activating a responsive theme + +The default themes do not render collapsible rows. To get the full responsive output, switch to one of the responsive themes: + +- `@KreyuDataTable/themes/bootstrap_5_responsive.html.twig` — extends the Bootstrap 5 theme. +- `@KreyuDataTable/themes/tabler_responsive.html.twig` — extends the Tabler theme. + +::: code-group +```yaml [YAML] +# config/packages/kreyu_data_table.yaml +kreyu_data_table: + defaults: + themes: + - '@KreyuDataTable/themes/bootstrap_5_responsive.html.twig' + responsive: + enabled: true +``` + +```php [PHP] +use Symfony\Config\KreyuDataTableConfig; + +return static function (KreyuDataTableConfig $config) { + $config->defaults()->themes([ + '@KreyuDataTable/themes/bootstrap_5_responsive.html.twig', + ]); + + $config->responsive()->enabled(true); +}; +``` +::: + +### Using responsive blocks in a custom theme + +If you maintain your own theme, you can reuse the shared responsive blocks via Twig's `{% use %}` tag. Each block is importable with an alias and can be selectively rendered depending on the `responsive_enabled` variable: + +```twig +{% extends '@KreyuDataTable/themes/bootstrap_5.html.twig' %} +{% use '@KreyuDataTable/themes/_responsive.html.twig' with + kreyu_data_table as _responsive_kreyu_data_table, + kreyu_data_table_attributes as _responsive_kreyu_data_table_attributes, + table_head_row as _responsive_table_head_row, + table_body_value_row as _responsive_table_body_value_row +%} + +{% block kreyu_data_table %}{{ responsive_enabled|default(false) ? block('_responsive_kreyu_data_table') : parent() }}{% endblock %} +{% block kreyu_data_table_attributes %}{{ responsive_enabled|default(false) ? block('_responsive_kreyu_data_table_attributes') : parent() }}{% endblock %} +{% block table_head_row %}{{ responsive_enabled|default(false) ? block('_responsive_table_head_row') : parent() }}{% endblock %} +{% block table_body_value_row %}{{ responsive_enabled|default(false) ? block('_responsive_table_body_value_row') : parent() }}{% endblock %} +``` + +The collapsible row layout can be further customized by overriding the `responsive_toggle_header`, `responsive_toggle_cell` and `responsive_collapsible_row` blocks. + +## How it works + +When responsive mode is enabled, the data table goes through the following lifecycle on every request: + +1. **Server-side estimation.** On the first render, the bundle asks the configured [`DeviceDetectorInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Responsive/DeviceDetectorInterface.php) (by default [`UserAgentDeviceDetector`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Responsive/UserAgentDeviceDetector.php)) to map the incoming User-Agent to a `Device` (`Phone`, `Tablet` or `Desktop`). This device is then translated to a breakpoint via a simple fallback: `Phone` → smallest configured breakpoint, `Tablet` → median one, `Desktop` → largest one. +2. **Initial render.** Columns whose `visible_from` breakpoint is greater than the active one are moved into a collapsible row. The rendered container is marked with the `kreyu-dt-responsive-pending` class so it stays invisible until the client confirms the active breakpoint — this avoids a flash of incorrectly sized content. +3. **Client-side measurement.** The Stimulus controller (`kreyu--data-table-bundle--responsive`) observes the `` width with a [`ResizeObserver`](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver). On `connect()`, if the measured breakpoint matches the server's, it simply removes the `kreyu-dt-responsive-pending` class to reveal the table. Otherwise, it performs a Turbo reload with `?_breakpoint=` appended to the frame URL so the server can re-render with the correct breakpoint. +4. **Ongoing resize handling.** Subsequent resizes go through a 250 ms debounce; a reload is triggered only when the computed breakpoint changes, not on every pixel change. + +The request handler reads the `_breakpoint` query parameter when present; otherwise it falls back to the server-side estimation. This keeps URLs shareable — a link captured on a desktop keeps rendering the "desktop" layout when pasted into another desktop browser. + +## Extending + +### Customizing device detection + +The User-Agent-based detector is deliberately conservative. If you have better information about the client (e.g. a CDN header, a device-class cookie, a user preference), provide your own implementation of [`DeviceDetectorInterface`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Responsive/DeviceDetectorInterface.php): + +```php +namespace App\DataTable\Responsive; + +use Kreyu\Bundle\DataTableBundle\Responsive\Device; +use Kreyu\Bundle\DataTableBundle\Responsive\DeviceDetectorInterface; +use Symfony\Component\HttpFoundation\Request; + +final class CloudFrontDeviceDetector implements DeviceDetectorInterface +{ + public function detect(Request $request): Device + { + return match ($request->headers->get('CloudFront-Is-Mobile-Viewer')) { + 'true' => Device::Phone, + default => match ($request->headers->get('CloudFront-Is-Tablet-Viewer')) { + 'true' => Device::Tablet, + default => Device::Desktop, + }, + }; + } +} +``` + +With autowiring enabled, aliasing the interface to your service is enough: + +```yaml +# config/services.yaml +services: + Kreyu\Bundle\DataTableBundle\Responsive\DeviceDetectorInterface: + alias: App\DataTable\Responsive\CloudFrontDeviceDetector +``` + +### Breakpoint names + +The [`Breakpoint`](https://github.com/Kreyu/data-table-bundle/blob/main/src/Responsive/Breakpoint.php) class exposes the default breakpoint names (`Breakpoint::SM`, `MD`, `LG`, `XL`) as constants, which can be used instead of raw strings in your column definitions: + +```php +use Kreyu\Bundle\DataTableBundle\Responsive\Breakpoint; + +$builder->addColumn('description', TextColumnType::class, [ + 'visible_from' => Breakpoint::LG, +]); +``` + +## Troubleshooting + +::: warning My column is never visible +Ensure both of the following: +- `responsive_enabled` is `true` on the data table (or globally). +- A responsive theme is configured in `defaults.themes` (e.g. `@KreyuDataTable/themes/bootstrap_5_responsive.html.twig`). Without it, the default theme will not render collapsible rows, so hidden columns simply disappear. +::: + +::: warning The breakpoint does not update when I resize the window +Verify that: +- Turbo is installed and the page is not served outside of its scope (the controller needs to locate the `` wrapping the table). +- The `responsive` Stimulus controller is enabled in `assets/controllers.json` (it is enabled by default). +- Your browser supports [`ResizeObserver`](https://caniuse.com/resizeobserver) — all evergreen browsers do. +::: + +::: warning I get "The 'visible_from' option references unknown breakpoint '…'" +The string passed to `visible_from` must match a breakpoint name declared in `responsive_breakpoints` (or the globally configured ones). Either use one of the defaults (`sm`, `md`, `lg`, `xl`), or make sure your custom breakpoint is registered before referencing it. +:::