diff --git a/assets/controllers/responsive.js b/assets/controllers/responsive.js new file mode 100644 index 00000000..ae67b335 --- /dev/null +++ b/assets/controllers/responsive.js @@ -0,0 +1,124 @@ +import { Controller } from '@hotwired/stimulus' +import '../styles/responsive.css' + +/* stimulusFetch: 'eager' */ +export default class extends Controller { + static targets = ['toggleButton', 'collapsibleRow'] + + static values = { + breakpoints: { type: Object, default: {} }, + currentBreakpoint: { type: String, default: '' }, + } + + #resizeObserver = null + #debounceTimeout = null + + connect() { + const frame = this.element.closest('turbo-frame') + + 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() { + if (this.#resizeObserver) { + this.#resizeObserver.disconnect() + this.#resizeObserver = null + } + + 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) + + if (!row) { + return + } + + 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(width) { + if (this.#debounceTimeout) { + clearTimeout(this.#debounceTimeout) + } + + this.#debounceTimeout = setTimeout(() => this.#detectAndUpdate(width), 250) + } + + #detectAndUpdate(width) { + const breakpoint = this.#resolveBreakpoint(width) + + if (breakpoint === this.currentBreakpointValue) { + return + } + + this.currentBreakpointValue = breakpoint + this.#reloadFrame(breakpoint) + } + + #resolveBreakpoint(width) { + const breakpoints = this.breakpointsValue + + for (const [name, maxWidth] of Object.entries(breakpoints)) { + if (width <= maxWidth) { + return name + } + } + + // Above all breakpoints: return the largest one + const names = Object.keys(breakpoints) + return names.length > 0 ? names[names.length - 1] : '' + } + + #reloadFrame(breakpoint) { + const frame = this.element.closest('turbo-frame') + + if (!frame) { + return + } + + const baseUrl = frame.getAttribute('src') || window.location.href + const url = new URL(baseUrl, window.location.origin) + url.searchParams.set('_breakpoint', breakpoint) + + 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..ee79bb03 --- /dev/null +++ b/assets/styles/responsive.css @@ -0,0 +1,39 @@ +.kreyu-dt-responsive-pending { + visibility: hidden; +} + +.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/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. +::: diff --git a/src/Column/Type/ColumnType.php b/src/Column/Type/ColumnType.php index 119f8bdc..f96329e5 100755 --- a/src/Column/Type/ColumnType.php +++ b/src/Column/Type/ColumnType.php @@ -73,6 +73,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 +121,7 @@ public function buildValueView(ColumnValueView $view, ColumnInterface $column, a 'translation_domain' => $translationDomain, 'translation_parameters' => $translationParameters ?? [], 'attr' => $attr, + 'visible_from' => $options['visible_from'], ]); } @@ -330,6 +332,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(null) + ->allowedTypes('null', 'string', 'bool') + ->allowedValues(static fn ($value) => $value !== true) + ->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.') + ; } public function getBlockPrefix(): string diff --git a/src/DataTable.php b/src/DataTable.php index c48a3303..2006abe6 100755 --- a/src/DataTable.php +++ b/src/DataTable.php @@ -113,6 +113,7 @@ class DataTable implements DataTableInterface private bool $initialized = false; private ?string $turboFrameId = null; + private ?string $activeBreakpoint = null; public function __construct( private ProxyQueryInterface $query, @@ -880,6 +881,18 @@ public function isRequestFromTurboFrame(): bool return null !== $this->turboFrameId && 'kreyu_data_table_'.$this->getName() === $this->turboFrameId; } + public function getActiveBreakpoint(): ?string + { + return $this->activeBreakpoint; + } + + public function setActiveBreakpoint(?string $activeBreakpoint): static + { + $this->activeBreakpoint = $activeBreakpoint; + + 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..011cc22d 100755 --- a/src/DataTableInterface.php +++ b/src/DataTableInterface.php @@ -219,4 +219,8 @@ public function createExportView(): DataTableView; public function setTurboFrameId(string $turboFrameId): static; public function isRequestFromTurboFrame(): bool; + + public function getActiveBreakpoint(): ?string; + + public function setActiveBreakpoint(?string $activeBreakpoint): static; } diff --git a/src/DependencyInjection/Configuration.php b/src/DependencyInjection/Configuration.php index e1ec2e20..dc7a58c8 100755 --- a/src/DependencyInjection/Configuration.php +++ b/src/DependencyInjection/Configuration.php @@ -151,6 +151,24 @@ public function getConfigTreeBuilder(): TreeBuilder ->end() ->end() ->end() + ->arrayNode('responsive') + ->addDefaultsIfNotSet() + ->children() + ->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') ->addDefaultsIfNotSet() ->children() diff --git a/src/DependencyInjection/KreyuDataTableExtension.php b/src/DependencyInjection/KreyuDataTableExtension.php index 57b9ddec..1fdfe37c 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; @@ -66,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')) { @@ -76,6 +81,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 @@ -98,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 c0a78ead..50a92669 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\BreakpointResolver; +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,27 @@ private function extractQueryParameter(Request $request, string $path): mixed return $this->propertyAccessor->getValue($request->query->all(), $path); } + private function detectDevice(DataTableInterface $dataTable, Request $request): void + { + if (!($dataTable->getConfig()->getOption('responsive_enabled') ?? false)) { + return; + } + + $breakpoints = $dataTable->getConfig()->getOption('responsive_breakpoints') ?? []; + $resolver = new BreakpointResolver($breakpoints); + $breakpoint = $request->query->get('_breakpoint'); + + if (null !== $breakpoint && $resolver->has($breakpoint)) { + $dataTable->setActiveBreakpoint($breakpoint); + + return; + } + + if (null !== $this->deviceDetector) { + $dataTable->setActiveBreakpoint($resolver->resolveUaFallback($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..f874acff --- /dev/null +++ b/src/Resources/views/themes/_responsive.html.twig @@ -0,0 +1,127 @@ +{% trans_default_domain 'KreyuDataTable' %} + +{# Responsive blocks — import via {% use %} in responsive themes #} +{# Provides collapsible rows for columns hidden at the current breakpoint #} + +{% block kreyu_data_table %} + {% 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) }} + + {% 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')) }}" + {% 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 %} + + {% if responsive_hidden_columns|default([])|length > 0 %} + {{ block('responsive_toggle_header', theme) }} + {% endif %} + + {% for column_header in header_row %} + {% if column_header.vars.name not in responsive_hidden_columns|default([]) %} + {{- data_table_column_header(column_header) -}} + {% endif %} + {% endfor %} + +{% endblock %} + +{% 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 %} + {% 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 }) %} + {% 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..80d3b8fb --- /dev/null +++ b/src/Resources/views/themes/bootstrap_5_responsive.html.twig @@ -0,0 +1,12 @@ +{% 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 %} 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..60e75c0b --- /dev/null +++ b/src/Resources/views/themes/tabler_responsive.html.twig @@ -0,0 +1,12 @@ +{% extends '@KreyuDataTable/themes/tabler.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/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 @@ + */ + private readonly array $breakpoints; + + /** + * @param array $breakpoints Associative array of name => max width + */ + public function __construct(array $breakpoints) + { + asort($breakpoints); + $this->breakpoints = $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. + */ + 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 === $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 + */ + 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/Responsive/Device.php b/src/Responsive/Device.php new file mode 100644 index 00000000..486f290d --- /dev/null +++ b/src/Responsive/Device.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..db0a4a55 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,6 +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\BreakpointResolver; use Kreyu\Bundle\DataTableBundle\RowIterator; use Kreyu\Bundle\DataTableBundle\Util\FormUtil; use Kreyu\Bundle\DataTableBundle\ValueRowView; @@ -107,8 +109,54 @@ 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(), + 'responsive_hidden_columns' => [], ]); + 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) && !$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; + } 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); $view->nonPersonalizedHeaderRow = $this->createHeaderRowView($view, $dataTable, $columns); $view->valueRows = new RowIterator(fn () => $this->createValueRowsViews($view, $dataTable, $visibleColumns)); @@ -186,6 +234,13 @@ 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, + 'lg' => 992, + 'xl' => 1200, + ], ]) ->setAllowedTypes('title', ['null', 'string', TranslatableInterface::class]) ->setAllowedTypes('title_translation_parameters', ['array']) @@ -217,6 +272,8 @@ 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/Column/Type/ColumnTypeTest.php b/tests/Unit/Column/Type/ColumnTypeTest.php index 358e19bd..5cdfd04a 100644 --- a/tests/Unit/Column/Type/ColumnTypeTest.php +++ b/tests/Unit/Column/Type/ColumnTypeTest.php @@ -1058,6 +1058,50 @@ public function testBlockPrefixesWithParent() $this->assertEquals($expectedBlockPrefixes, $columnValueView->vars['block_prefixes']); } + public function testDefaultVisibleFromIsNull(): void + { + $column = $this->createNamedColumn('firstName'); + + $columnHeaderView = $this->createColumnHeaderView($column); + $columnValueView = $this->createColumnValueView($column); + + $this->assertNull($columnHeaderView->vars['visible_from']); + $this->assertNull($columnValueView->vars['visible_from']); + } + + #[DataProvider('provideVisibleFromOptions')] + public function testPassingVisibleFromOption(string|false|null $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 '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 + { + $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/BreakpointResolverTest.php b/tests/Unit/Responsive/BreakpointResolverTest.php new file mode 100644 index 00000000..a16ab717 --- /dev/null +++ b/tests/Unit/Responsive/BreakpointResolverTest.php @@ -0,0 +1,189 @@ +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 testIsVisibleWithUnknownMinimumBreakpointReturnsFalse(): void + { + $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')] + 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/DeviceTest.php b/tests/Unit/Responsive/DeviceTest.php new file mode 100644 index 00000000..2277949f --- /dev/null +++ b/tests/Unit/Responsive/DeviceTest.php @@ -0,0 +1,31 @@ +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..e6d45f41 --- /dev/null +++ b/tests/Unit/Responsive/HttpFoundationRequestHandlerDeviceDetectionTest.php @@ -0,0 +1,169 @@ + 576, + 'md' => 768, + 'lg' => 992, + 'xl' => 1200, + ]; + + public function testBreakpointParameterOverridesUserAgent(): void + { + $detector = $this->createMock(DeviceDetectorInterface::class); + $detector->expects($this->never())->method('detect'); + + $handler = new HttpFoundationRequestHandler($detector); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setActiveBreakpoint') + ->with('sm'); + + $handler->handle($dataTable, $this->createRequest(['_breakpoint' => 'sm'])); + } + + public function testFallsBackToUserAgentWhenNoBreakpointParameter(): 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('setActiveBreakpoint') + ->with('lg'); // Tablet → median upper breakpoint (lg for 4 breakpoints) + + $handler->handle($dataTable, $this->createRequest()); + } + + public function testFallsBackToUserAgentPhoneGivesSmallestBreakpoint(): void + { + $detector = $this->createMock(DeviceDetectorInterface::class); + $detector->expects($this->once()) + ->method('detect') + ->willReturn(Device::Phone); + + $handler = new HttpFoundationRequestHandler($detector); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setActiveBreakpoint') + ->with('sm'); + + $handler->handle($dataTable, $this->createRequest()); + } + + public function testFallsBackToUserAgentDesktopGivesLargestBreakpoint(): 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'); // Desktop → largest breakpoint + + $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(); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->never())->method('setActiveBreakpoint'); + + $handler->handle($dataTable, $this->createRequest()); + } + + public function testBreakpointParameterWorksWithoutDetector(): void + { + $handler = new HttpFoundationRequestHandler(); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->once()) + ->method('setActiveBreakpoint') + ->with('md'); + + $handler->handle($dataTable, $this->createRequest(['_breakpoint' => 'md'])); + } + + public function testNullRequestDoesNothing(): void + { + $handler = new HttpFoundationRequestHandler(); + + $dataTable = $this->createDataTableMock(); + $dataTable->expects($this->never())->method('setActiveBreakpoint'); + + $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); + $config->method('getOption') + ->willReturnCallback(function (string $name) { + return match ($name) { + 'responsive_enabled' => true, + 'responsive_breakpoints' => self::DEFAULT_BREAKPOINTS, + default => null, + }; + }); + + $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)); + } +} 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); + } +}