diff --git a/resources/css/tailwind4.css b/resources/css/tailwind4.css index bfd5d302..92021c84 100644 --- a/resources/css/tailwind4.css +++ b/resources/css/tailwind4.css @@ -3,7 +3,7 @@ @custom-variant dark (&:where(.dark, .dark *)); -@source '../src/Themes/Tailwind.php'; +@source '../src/Themes/**/*.php'; @source '../resources/views/**/*.php'; @source '../resources/js/**/*.js'; diff --git a/resources/views/components/structure/header.blade.php b/resources/views/components/structure/header.blade.php index ed196ee2..dcc61a79 100644 --- a/resources/views/components/structure/header.blade.php +++ b/resources/views/components/structure/header.blade.php @@ -14,10 +14,6 @@ class="pg-header-container" wire:key="header-{{ $tableName }}" > @includeIf(data_get($setUp, 'header.includeViewOnTop'), ['__partial' => $__partial]) - @php($tabsZone = $__partial->renderPluginZone('header.tabs')) - @if (trim($tabsZone) !== '') -
{!! $tabsZone !!}
- @endif
diff --git a/resources/views/components/themes/flux/table/column-tooltip.blade.php b/resources/views/components/themes/flux/table/column-tooltip.blade.php new file mode 100644 index 00000000..0a69d385 --- /dev/null +++ b/resources/views/components/themes/flux/table/column-tooltip.blade.php @@ -0,0 +1,11 @@ +@props([ + 'display' => '', + 'full' => '', + 'position' => 'top', +]) + +{{-- Flux tooltip: shows the full, untruncated value on hover/focus. + $display is already escaped; $full is the raw value escaped by Flux. --}} + + {!! $display !!} + diff --git a/src/Column.php b/src/Column.php index 04c6d111..1fd7bc06 100644 --- a/src/Column.php +++ b/src/Column.php @@ -4,5 +4,10 @@ /** * @see \PowerComponents\Turbine\Column + * + * Plugin macros + * + * @method $this limit(int $characters, string $end = '...') Truncate the displayed value to N characters with an ellipsis (TruncatePlugin). + * @method $this tooltip(bool $enabled = true, string $position = 'top') Show the full, untruncated value in a theme-aware tooltip (TruncatePlugin). */ class Column extends \PowerComponents\Turbine\Column {} diff --git a/src/Plugins/Tabs/TabsPlugin.php b/src/Plugins/Tabs/TabsPlugin.php index eb133448..8e07fd32 100644 --- a/src/Plugins/Tabs/TabsPlugin.php +++ b/src/Plugins/Tabs/TabsPlugin.php @@ -18,7 +18,7 @@ public function isEnabled(): bool public function handlesZone(string $zone): bool { - return $zone === 'header.tabs' && $this->isEnabled(); + return $zone === 'top' && $this->isEnabled(); } public function renderZone(string $zone): ?string diff --git a/src/Plugins/Truncate/TruncatePlugin.php b/src/Plugins/Truncate/TruncatePlugin.php new file mode 100644 index 00000000..0856838c --- /dev/null +++ b/src/Plugins/Truncate/TruncatePlugin.php @@ -0,0 +1,52 @@ +limit()` and `->tooltip()` to columns. + * + * `->limit(24)` truncates the displayed value to 24 characters using an + * ellipsis. `->tooltip()` renders the full, untruncated value in a + * theme-aware tooltip (Flux uses ; other themes fall back to a + * native `title` attribute). + * + * This plugin only registers the macros — the actual truncation/tooltip + * rendering is applied in the shared table row view so it composes with the + * column's existing content classes and value handling instead of replacing + * them (hence handles() stays false and no render() override is provided). + */ +class TruncatePlugin extends PluginBase +{ + public static function boot(): void + { + Column::macro('limit', function (int $characters, string $end = '...'): Column { + /** @var Column $this */ + $this->pluginData['truncate']['limit'] = $characters; + $this->pluginData['truncate']['end'] = $end; + + return $this; + }); + + Column::macro('tooltip', function (bool $enabled = true, string $position = 'top'): Column { + /** @var Column $this */ + $this->pluginData['truncate']['tooltip'] = $enabled; + $this->pluginData['truncate']['position'] = $position; + + return $this; + }); + } + + public function name(): string + { + return 'truncate'; + } + + public function isEnabled(): bool + { + return collect($this->component->declaredColumns()) + ->contains(fn ($column) => ! empty(data_get($column, 'pluginData.truncate'))); + } +} diff --git a/src/PowerGridManager.php b/src/PowerGridManager.php index 86d12205..48355b9d 100644 --- a/src/PowerGridManager.php +++ b/src/PowerGridManager.php @@ -10,6 +10,7 @@ use PowerComponents\LivewirePowerGrid\Plugins\PluginBase; use PowerComponents\LivewirePowerGrid\Plugins\Tabs\TabsPlugin; use PowerComponents\LivewirePowerGrid\Plugins\Toggleable\ToggleablePlugin; +use PowerComponents\LivewirePowerGrid\Plugins\Truncate\TruncatePlugin; use PowerComponents\Turbine\Components\SetUp\{Cache, Detail, FilterBuilder, Footer, Header, Responsive}; class PowerGridManager @@ -27,6 +28,7 @@ class PowerGridManager FlatpickrPlugin::class, TabsPlugin::class, ToggleablePlugin::class, + TruncatePlugin::class, ]; /** @var list> */ diff --git a/src/Support/CellRenderer.php b/src/Support/CellRenderer.php index 31d5f2c0..4cf4bb48 100644 --- a/src/Support/CellRenderer.php +++ b/src/Support/CellRenderer.php @@ -97,26 +97,68 @@ private function renderContentCell(ColumnViewModel $column, object $row, int $ro $rawContent = $rawContent instanceof \BackedEnum ? $rawContent->value : $rawContent->name; } - $content = match (true) { - $rawContent instanceof Htmlable => $rawContent->toHtml(), - is_scalar($rawContent) || $rawContent instanceof \Stringable => e((string) $rawContent), - default => '', - }; + $truncate = data_get($column->column, 'pluginData.truncate'); + $tooltipFull = null; + + if ($rawContent instanceof Htmlable) { + $content = $rawContent->toHtml(); + } elseif (is_scalar($rawContent) || $rawContent instanceof \Stringable) { + $stringValue = (string) $rawContent; + + if (is_array($truncate) && filled($stringValue) && ! $column->index && ($limit = data_get($truncate, 'limit'))) { + $truncated = \Illuminate\Support\Str::limit($stringValue, (int) $limit, data_get($truncate, 'end', '...')); + + // Only keep the full value for a tooltip when the text was actually + // shortened — a value below the limit already shows in full. + if ($truncated !== $stringValue) { + $tooltipFull = $stringValue; + $stringValue = $truncated; + } + } + + $content = e($stringValue); + } else { + $content = ''; + } $spanClass = $column->spanClassStatic ?? Arr::toCssClasses([ $column->contentClassField, $this->contentClassFor($column, $content), ]); - $inner = filled($templateContent) - ? '
' - : '
'.($column->index ? $rowIndex : $content).'
'; + .'" x-html="rendered">
', + $tooltipView !== '' => $this->renderTooltip( + $tooltipView, + $content, + (string) $tooltipFull, + (string) data_get($truncate, 'position', 'top'), + ), + default => '
'.($column->index ? $rowIndex : $content).'
', + }; return ''.$inner.''; } + /** @param view-string $view */ + private function renderTooltip(string $view, string $display, string $full, string $position): string + { + return view($view, [ + 'display' => $display, + 'full' => $full, + 'position' => $position, + ])->render(); + } + private function toHtmlString(mixed $value): string { return match (true) { diff --git a/src/Themes/Flux.php b/src/Themes/Flux.php index cec7fcd2..883c65d0 100644 --- a/src/Themes/Flux.php +++ b/src/Themes/Flux.php @@ -21,6 +21,7 @@ public function struct(): Components\ThemeBuilder return Components\ThemeBuilder::make($this->name()) ->baseView('livewire-powergrid::components.themes.flux') ->layout(fn (Components\Layout $layout) => $layout + ->container('space-y-4') ->wrapper('space-y-4') ->card('rounded-xl border border-zinc-200 bg-white dark:border-zinc-700 dark:bg-zinc-900 overflow-visible') ->outsideFilters('') @@ -79,20 +80,20 @@ public function struct(): Components\ThemeBuilder ->tr('border-b border-zinc-100 dark:border-zinc-700 hover:bg-zinc-50 dark:hover:bg-zinc-800/60') ->theadTr('hover:bg-transparent! dark:hover:bg-transparent!') ->emptyState('px-6 py-12 text-center text-sm text-zinc-500 dark:text-zinc-400') - ->th('py-3 px-3 first:ps-0 last:pe-0 text-start text-sm font-medium text-zinc-800 dark:text-white border-b border-zinc-800/10 dark:border-white/20 whitespace-nowrap') - ->thActions('font-semibold px-3 py-3 text-end text-xs text-zinc-500 tracking-wider whitespace-nowrap dark:text-zinc-400') + ->th('font-medium px-3 py-3 first:ps-4 last:pe-4 text-start text-sm text-zinc-800 whitespace-nowrap dark:text-white') + ->thActions('font-medium px-3 py-3 first:ps-4 last:pe-4 text-end text-sm text-zinc-800 whitespace-nowrap dark:text-white') ->tbody('text-sm text-zinc-800 dark:text-zinc-200') - ->td('px-3 py-2 first:ps-0 last:pe-0 whitespace-nowrap') - ->tdActions('px-3 py-2 first:ps-0 last:pe-0 whitespace-nowrap text-end') + ->td('px-3 py-3 first:ps-4 last:pe-4 whitespace-nowrap') + ->tdActions('px-3 py-3 first:ps-4 last:pe-4 whitespace-nowrap text-end') ) ->checkbox(fn (Components\Checkbox $checkbox) => $checkbox - ->th('py-2 ps-4! pe-3 text-start align-middle') + ->th('px-3 py-3 ps-4 text-left text-xs font-medium text-zinc-500 tracking-wider') ->base('flex items-center') ->label('flex items-center') - ->input('rounded border-zinc-200 dark:border-white/10 h-4 w-4 accent-[var(--color-accent)] focus:ring-accent focus:ring-offset-0') + ->input('rounded border-zinc-200 dark:border-white/10 bg-white dark:bg-white/10 h-4 w-4 accent-[var(--color-accent)] focus:ring-accent focus:ring-offset-0') ) ->radio(fn (Components\Radio $radio) => $radio - ->th('px-6 py-3 text-left text-xs font-medium text-zinc-500 tracking-wider') + ->th('px-3 py-3 first:ps-4 last:pe-4 text-left text-xs font-medium text-zinc-500 tracking-wider') ->input('rounded-full border-zinc-200 dark:border-white/10 h-4 w-4 accent-[var(--color-accent)] focus:ring-accent focus:ring-offset-0') ) ) @@ -166,7 +167,7 @@ public function filter(): array ->wrapper('relative inline-block text-left') ->trigger($this->triggerButton()) ->badge('absolute -top-1.5 -right-1.5 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-accent px-1.5 text-xs font-semibold text-accent-foreground') - ->panel('fixed inset-x-4 top-4 z-50 flex max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] flex-col overflow-hidden origin-top rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900') + ->panel('fixed inset-x-4 top-4 z-50 flex max-h-[calc(100dvh-2rem)] max-w-[calc(100vw-2rem)] flex-col overflow-hidden origin-top rounded-xl border border-zinc-200 bg-white shadow-lg dark:border-zinc-700 dark:bg-zinc-900 lg:absolute lg:inset-x-auto lg:top-auto lg:left-auto lg:right-0 lg:mt-2') ->header('flex shrink-0 items-center justify-between px-4 pt-4 pb-2') ->title('text-base font-semibold text-zinc-800 dark:text-zinc-100') ->body('min-h-0 flex-1 overflow-y-auto px-4 py-3') diff --git a/tests/Feature/Plugins/TruncateTest.php b/tests/Feature/Plugins/TruncateTest.php new file mode 100644 index 00000000..d58f08fb --- /dev/null +++ b/tests/Feature/Plugins/TruncateTest.php @@ -0,0 +1,122 @@ + 1, 'name' => 'A very long dish name']]); + } + + public function fields(): PowerGridFields + { + return PowerGrid::fields()->add('id')->add('name'); + } + + public function columns(): array + { + return [ + Column::make('Id', 'id'), + Column::make('Name', 'name')->limit(6), + ]; + } + }; + + Livewire::test($component::class) + ->assertSee('A very...') + ->assertDontSee('A very long dish name'); +}); + +it('does not render a tooltip outside the flux theme', function () { + // The tooltip is opt-in per theme (only Flux ships the view). Under the + // default theme, ->tooltip() must not leak the full value into the markup. + $component = new class() extends PowerGridComponent + { + public string $tableName = 'test-truncate-tooltip-default'; + + public function datasource() + { + return collect([['id' => 1, 'name' => 'A very long dish name']]); + } + + public function fields(): PowerGridFields + { + return PowerGrid::fields()->add('id')->add('name'); + } + + public function columns(): array + { + return [ + Column::make('Id', 'id'), + Column::make('Name', 'name')->limit(6)->tooltip(), + ]; + } + }; + + Livewire::test($component::class) + ->assertSee('A very...') + ->assertDontSee('A very long dish name'); +}); + +it('does not truncate nor tooltip when the value is shorter than the limit', function () { + $component = new class() extends PowerGridComponent + { + public string $tableName = 'test-truncate-short'; + + public function datasource() + { + return collect([['id' => 1, 'name' => 'Short']]); + } + + public function fields(): PowerGridFields + { + return PowerGrid::fields()->add('id')->add('name'); + } + + public function columns(): array + { + return [ + Column::make('Id', 'id'), + Column::make('Name', 'name')->limit(50)->tooltip(), + ]; + } + }; + + Livewire::test($component::class) + ->assertSee('Short') + ->assertDontSee('...'); +}); + +it('does not truncate when no limit is set', function () { + $component = new class() extends PowerGridComponent + { + public string $tableName = 'test-truncate-off'; + + public function datasource() + { + return collect([['id' => 1, 'name' => 'A very long dish name']]); + } + + public function fields(): PowerGridFields + { + return PowerGrid::fields()->add('id')->add('name'); + } + + public function columns(): array + { + return [ + Column::make('Id', 'id'), + Column::make('Name', 'name'), + ]; + } + }; + + Livewire::test($component::class) + ->assertSee('A very long dish name'); +});