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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion resources/css/tailwind4.css
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
4 changes: 0 additions & 4 deletions resources/views/components/structure/header.blade.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) !== '')
<div class="pg-tabs-row px-4 pt-4">{!! $tabsZone !!}</div>
@endif
<header class="pg-header {{ theme('header.layout.container') }}">
<div class="pg-header-sub {{ theme('header.layout.sub_container') }}">
<div class="pg-actions {{ theme('header.layout.actions_container') }}">
Expand Down
Original file line number Diff line number Diff line change
@@ -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. --}}
<flux:tooltip :content="$full" :position="$position">
<span>{!! $display !!}</span>
</flux:tooltip>
5 changes: 5 additions & 0 deletions src/Column.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 {}
2 changes: 1 addition & 1 deletion src/Plugins/Tabs/TabsPlugin.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions src/Plugins/Truncate/TruncatePlugin.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

namespace PowerComponents\LivewirePowerGrid\Plugins\Truncate;

use PowerComponents\LivewirePowerGrid\Column;
use PowerComponents\LivewirePowerGrid\Plugins\PluginBase;

/**
* Adds `->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 <flux:tooltip>; 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')));
}
}
2 changes: 2 additions & 0 deletions src/PowerGridManager.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -27,6 +28,7 @@ class PowerGridManager
FlatpickrPlugin::class,
TabsPlugin::class,
ToggleablePlugin::class,
TruncatePlugin::class,
];

/** @var list<class-string<PluginBase>> */
Expand Down
60 changes: 51 additions & 9 deletions src/Support/CellRenderer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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)
? '<div x-data="pgRenderRowTemplate" data-pg-params="'
// Tooltip is opt-in per theme: only themes that ship a
// `table.column-tooltip` view render it (Flux). Other themes fall
// through to plain (truncated) text.
$tooltipView = ($tooltipFull !== null && (bool) data_get($truncate, 'tooltip'))
? theme_view('table.column-tooltip')
: '';

$inner = match (true) {
filled($templateContent) => '<div x-data="pgRenderRowTemplate" data-pg-params="'
.e((string) json_encode(['parentId' => $parentId, 'templateContent' => $templateContent]))
.'" x-html="rendered"></div>'
: '<div>'.($column->index ? $rowIndex : $content).'</div>';
.'" x-html="rendered"></div>',
$tooltipView !== '' => $this->renderTooltip(
$tooltipView,
$content,
(string) $tooltipFull,
(string) data_get($truncate, 'position', 'top'),
),
default => '<div>'.($column->index ? $rowIndex : $content).'</div>',
};

return '<span class="'.$spanClass.'">'.$inner.'</span>';
}

/** @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) {
Expand Down
17 changes: 9 additions & 8 deletions src/Themes/Flux.php
Original file line number Diff line number Diff line change
Expand Up @@ -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('')
Expand Down Expand Up @@ -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')
)
)
Expand Down Expand Up @@ -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')
Expand Down
122 changes: 122 additions & 0 deletions tests/Feature/Plugins/TruncateTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
<?php

use Livewire\Livewire;
use PowerComponents\LivewirePowerGrid\{Column, PowerGridComponent, PowerGridFields};
use PowerComponents\LivewirePowerGrid\Facades\PowerGrid;

it('truncates the column value with an ellipsis', function () {
$component = new class() extends PowerGridComponent
{
public string $tableName = 'test-truncate-limit';

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),
];
}
};

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');
});