` and the `` chrome already make.
+ *
+ * Worth it because this cell was the most expensive thing left in the row:
+ * measured at 2 251 B and 10 whitespace text nodes per row, more than the entire
+ * rest of a three-column row. Memoised per table instance, which is also per
+ * render — Livewire rebuilds the Table on every request, so nothing goes stale.
+ */
+ public function getSelectionCellSkeleton(): Skeleton
+ {
+ return $this->selectionCellSkeleton ??= Skeleton::compile(
+ view('wire-table::tables.partials.selection-cell', [
+ 'cellPadding' => $this->getCellPadding(),
+ 'usesRangeSelection' => $this->usesRangeSelection(),
+ 'checkIcon' => $this->getSelectionCheckIcon(),
+ 'keyJs' => Skeleton::slot('keyJs'),
+ ])->render(),
+ 'keyJs',
+ );
+ }
+
/**
* Enable model policy auto-resolution.
*
@@ -1313,6 +1383,22 @@ public function isCompact(): bool
return $this->compact;
}
+ /**
+ * The body cell's padding utilities. One owner for the density map, so the row
+ * view and anything that renders a cell outside it (the selection cell) cannot
+ * drift apart.
+ */
+ public function getCellPadding(): string
+ {
+ return $this->compact ? 'px-4 py-2' : 'px-6 py-4';
+ }
+
+ /** The header cell's padding utilities — {@see getCellPadding()}. */
+ public function getHeaderPadding(): string
+ {
+ return $this->compact ? 'px-4 py-2' : 'px-6 py-3';
+ }
+
/**
* Set bordered mode
*/
@@ -1489,7 +1575,9 @@ protected function flattenMobileRowActions(): array
foreach ($this->getMobileRowActionsForDisplay() as $action) {
if ($action instanceof ActionGroup) {
foreach ($action->getActions() as $inner) {
- if ($inner instanceof Action && $inner->isDivider()) {
+ // Dividers are chrome, and a group's record-less members
+ // belong to another surface than a row's actions.
+ if (! $inner instanceof Action || $inner->isDivider()) {
continue;
}
@@ -1557,7 +1645,7 @@ public function getMobileSubRowActionGroup(): ActionGroup
}
/**
- * @param array $actions
+ * @param array $actions
*/
private function buildMobileActionGroup(array $actions): ActionGroup
{
@@ -1566,6 +1654,106 @@ private function buildMobileActionGroup(array $actions): ActionGroup
->mobileBreakpoint($this->getMobileBreakpoint());
}
+ /**
+ * Collapse the toolbar's header actions into one dropdown group on a phone,
+ * so a narrow toolbar shows a single "⋮" trigger instead of several labelled
+ * buttons competing with the search field, the filters and the view menu.
+ *
+ * Unlike {@see collapseActionsOnMobile()} this needs no `stackedOnMobile()`:
+ * the toolbar is the same toolbar at every width, so the collapse is purely a
+ * width switch. **Desktop is untouched** — from the mobile breakpoint up the
+ * inline buttons render exactly as before; the breakpoint is the table's
+ * {@see mobileBreakpoint()} (`sm` by default, i.e. below 640px).
+ *
+ * The collapse only kicks in once the toolbar carries at least `$threshold`
+ * executable header actions (default 2 — one button alone is not a crowd, and
+ * the toolbar folds sooner than a card's row actions because it also holds the
+ * search field and the view menu). The threshold is clamped to at least 1.
+ */
+ public function collapseHeaderActionsOnMobile(bool $collapse = true, int $threshold = 2): static
+ {
+ $this->collapseHeaderActionsOnMobile = $collapse;
+ $this->collapseHeaderActionsOnMobileThreshold = max(1, $threshold);
+
+ return $this;
+ }
+
+ public function getCollapseHeaderActionsOnMobileThreshold(): int
+ {
+ return $this->collapseHeaderActionsOnMobileThreshold;
+ }
+
+ /**
+ * Whether the toolbar should collapse its header actions on a phone: the
+ * feature is enabled and at least the configured threshold of header actions
+ * would actually render. The count only includes actions the viewer may run,
+ * because those are the ones that reach the toolbar at all — a table whose
+ * per-viewer guards leave one action keeps that action as a plain button.
+ */
+ public function shouldCollapseHeaderActionsOnMobile(): bool
+ {
+ return $this->collapseHeaderActionsOnMobile
+ && count($this->executableHeaderActions()) >= $this->collapseHeaderActionsOnMobileThreshold;
+ }
+
+ /**
+ * The header actions that reach the toolbar at all: the ones the viewer may
+ * run. Shared by the collapse threshold and {@see getMobileHeaderActionGroup()}
+ * so the count matches what the dropdown would really contain — the inline
+ * buttons drop a guarded action the same way.
+ *
+ * @return array
+ */
+ protected function executableHeaderActions(): array
+ {
+ return array_values(array_filter(
+ $this->headerActions,
+ fn (BaseAction $action): bool => $action->canExecute(),
+ ));
+ }
+
+ /**
+ * Canonical builder for the toolbar's collapsed header-action dropdown: the
+ * same {@see ActionGroup} the row actions collapse into, so a phone gets one
+ * dropdown vocabulary rather than two.
+ *
+ * Both halves sit in the document at every width — CSS decides which is shown
+ * — so the collapsed copy drops each action's `keyboardShortcut()`: a rendered
+ * menu row binds it as a *window* listener, and two of them would answer one
+ * keypress twice. Same reason the mobile row actions and the mobile empty
+ * state clone.
+ */
+ public function getMobileHeaderActionGroup(): ActionGroup
+ {
+ return $this->buildMobileActionGroup(array_map(
+ fn (BaseAction $action): BaseAction => (clone $action)->withoutKeyboardShortcut(),
+ $this->executableHeaderActions(),
+ ));
+ }
+
+ /**
+ * Responsive class for the toolbar's inline header actions: hidden below the
+ * mobile breakpoint (the dropdown stands in for them), a plain flex row from
+ * it up. Empty while the collapse is off, so the buttons render unwrapped.
+ */
+ public function getInlineHeaderActionsClass(): string
+ {
+ if (! $this->shouldCollapseHeaderActionsOnMobile()) {
+ return '';
+ }
+
+ return Breakpoint::resolve($this->getMobileBreakpoint())->flexFromClass();
+ }
+
+ /**
+ * Companion to {@see getInlineHeaderActionsClass()}: shows the collapsed
+ * dropdown only below the mobile breakpoint.
+ */
+ public function getMobileHeaderActionsVisibleClass(): string
+ {
+ return Breakpoint::resolve($this->getMobileBreakpoint())->hiddenAtClass();
+ }
+
/**
* Set custom table class
*/
@@ -1976,6 +2164,115 @@ public function getRowContextMenuHtml(Model $record): Htmlable
return new HtmlString($html);
}
+ /**
+ * A group's header row, rendered once for the table and spliced per group.
+ *
+ * Only the label varies, so this is a one-slot skeleton. It matters because group
+ * count is not bounded by anything: grouped by a status there are three of these,
+ * grouped by a date there is one per row — and it used to be a view render and six
+ * DOM text nodes either way.
+ *
+ * The label is escaped here, at the one place that knows it is text content.
+ */
+ public function getGroupHeaderRow(Model $record, int $colSpan): string
+ {
+ $skeleton = $this->groupHeaderSkeleton ??= Skeleton::compile(
+ view('wire-table::tables.partials.group-header', [
+ 'colSpan' => $colSpan,
+ 'cellPadding' => $this->getCellPadding(),
+ 'label' => Skeleton::slot('label'),
+ ])->render(),
+ 'label',
+ );
+
+ return $skeleton->fill(['label' => e((string) $this->resolveGroupLabel($record))]);
+ }
+
+ /**
+ * The sub-row expander cell for one row, rendered once per shape and spliced.
+ *
+ * The cell has exactly three shapes — no toggle, toggle collapsed, toggle expanded
+ * — and every row is one of them, so `tables.partials.sub-row-cell` is rendered at
+ * most three times per table instead of once per row. Before this it was an
+ * `@include` inside the row loop, which is the N×View anti-pattern the render fuse
+ * exists to catch: 1 044 B, 8 whitespace text nodes and a view render per row.
+ *
+ * A record with no sub-rows still gets the cell, empty — drop it and the columns
+ * stop lining up.
+ *
+ * @param string $keyJs the record key, already encoded for an Alpine expression
+ */
+ public function getSubRowCell(string $keyJs, bool $hasToggle, bool $isExpanded): string
+ {
+ $shape = ($hasToggle ? 't' : '-').($isExpanded ? 'e' : '-');
+
+ $skeleton = $this->subRowCellSkeletons[$shape] ??= Skeleton::compile(
+ view('wire-table::tables.partials.sub-row-cell', [
+ 'cellPadding' => $this->getCellPadding(),
+ 'borderClass' => $this->isBordered() ? 'border border-gray-200 dark:border-gray-700' : '',
+ 'hasToggle' => $hasToggle,
+ 'isExpanded' => $isExpanded,
+ 'keyJs' => Skeleton::slot('keyJs'),
+ ])->render(),
+ 'keyJs',
+ );
+
+ return $skeleton->fill(['keyJs' => $keyJs]);
+ }
+
+ /**
+ * The teleported context-menu panel, rendered once and spliced per row.
+ *
+ * The panel is pure scaffolding — position, colours, `role="menu"` — and it holds
+ * no per-row Alpine state, because one `wireRecordActions` controller on the
+ * `` drives every row's menu by record key. So the whole thing is one shape
+ * with two holes: the key, and the item markup {@see getRowContextMenuHtml()}
+ * already renders per record.
+ *
+ * Rendering the scaffolding per row cost a measured 1 659 B and 14 whitespace text
+ * nodes per row for a one-item menu, most of it identical on every row.
+ *
+ * Memoised per table instance, which is per render — Livewire rebuilds the Table on
+ * every request, so nothing goes stale.
+ */
+ public function getRowContextMenuSkeleton(): Skeleton
+ {
+ return $this->rowContextMenuSkeleton ??= Skeleton::compile(
+ view('wire-table::tables.partials.record-context-menu', [
+ 'key' => Skeleton::slot('key'),
+ 'menu' => Skeleton::slot('menu'),
+ ])->render(),
+ 'key',
+ 'menu',
+ );
+ }
+
+ /**
+ * This record's context-menu panel, or an empty string when the row has no visible
+ * action.
+ *
+ * The caller still wraps the echo in an `@if`, and must: the morph markers that
+ * conditional emits are what let morphdom pair a row's children when the cell list
+ * changes under it (a column reorder). See the note at the call site.
+ */
+ public function getRowContextMenuPanel(Model $record): string
+ {
+ if (! $this->hasRowContextMenu()) {
+ return '';
+ }
+
+ $menu = trim($this->getRowContextMenuHtml($record)->toHtml());
+
+ if ($menu === '') {
+ return '';
+ }
+
+ return $this->getRowContextMenuSkeleton()->fill([
+ 'key' => e((string) $record->{$this->getPrimaryKey()}),
+ 'menu' => $menu,
+ ]);
+ }
+
// Record actions (row-level interaction: click, double-click, right-click, keys)
/**
diff --git a/packages/table/src/WireTableServiceProvider.php b/packages/table/src/WireTableServiceProvider.php
index daffb23f..dd426ad8 100644
--- a/packages/table/src/WireTableServiceProvider.php
+++ b/packages/table/src/WireTableServiceProvider.php
@@ -10,8 +10,7 @@
use NyonCode\LaravelPackageToolkit\Packager;
use NyonCode\LaravelPackageToolkit\PackageServiceProvider;
use NyonCode\WireCore\Actions\Action;
-use NyonCode\WireCore\Foundation\Assets\AssetManager;
-use NyonCode\WireCore\Foundation\Assets\Js;
+use NyonCode\WireCore\Foundation\Assets\Bundle;
use NyonCode\WireTable\Livewire\TableStateSynthesizer;
use NyonCode\WireTable\Support\RecordAction;
use Symfony\Component\HttpFoundation\BinaryFileResponse;
@@ -37,11 +36,15 @@ public function configure(Packager $packager): void
$this->registerRecordActionMacros();
$this->registerAssetRoutes();
- $this->registerAssets();
})
->hasConfig()
->hasViews()
- ->hasAssets('dist')
+ ->hasAssets('dist', entries: [
+ Bundle::make('wire-table-records.js'),
+ Bundle::make('wire-table-selection.js'),
+ Bundle::make('wire-table-live.js'),
+ ])
+ ->hasAssetFallback(Bundle::servedByRoute('wire-table'))
->hasMigrations()
->hasTranslations()
->hasAbout()
@@ -104,27 +107,6 @@ protected function registerAssetRoutes(): void
->name('wire-table.asset');
}
- /**
- * Declare the table's browser bundles with the canonical AssetManager, so an app
- * that renders `@wireStackScripts` in its layout carries `wireRecordActions` and
- * `wireRecordSelection` on every page — including one with no table, which is
- * the page a `wire:navigate` visit to a table is made *from*.
- */
- protected function registerAssets(): void
- {
- app(AssetManager::class)->register([
- Js::make('records', self::ASSETS_PATH.'/wire-table-records.js')
- ->navigateTrack()
- ->navigateOnce(),
- Js::make('selection', self::ASSETS_PATH.'/wire-table-selection.js')
- ->navigateTrack()
- ->navigateOnce(),
- Js::make('live', self::ASSETS_PATH.'/wire-table-live.js')
- ->navigateTrack()
- ->navigateOnce(),
- ], 'wire-table');
- }
-
/**
* Extra rows for this package's `php artisan about` section (the toolkit
* already prepends "Version"). Values are closures so config resolves at
diff --git a/packages/table/tests/Feature/CollapseMobileHeaderActionsTest.php b/packages/table/tests/Feature/CollapseMobileHeaderActionsTest.php
new file mode 100644
index 00000000..f41ddd8a
--- /dev/null
+++ b/packages/table/tests/Feature/CollapseMobileHeaderActionsTest.php
@@ -0,0 +1,134 @@
+model(CollapseHeaderActionsUser::class)
+ ->columns([TextColumn::make('name')])
+ ->headerActions([
+ HeaderAction::make('create')->label('New user')->keyboardShortcut('c'),
+ HeaderAction::make('import')->label('Import')->requiresConfirmation(),
+ ])
+ ->collapseHeaderActionsOnMobile($this->collapse)
+ ->paginated(false);
+ }
+
+ public function render()
+ {
+ return $this->getTableProperty();
+ }
+}
+
+class CollapseHeaderActionsSingleHost extends Component
+{
+ use WithTable;
+
+ public function table(Table $table): Table
+ {
+ return $table
+ ->model(CollapseHeaderActionsUser::class)
+ ->columns([TextColumn::make('name')])
+ ->headerActions([
+ HeaderAction::make('create')->label('New user'),
+ HeaderAction::make('import')->label('Import')->visible(false),
+ ])
+ ->collapseHeaderActionsOnMobile(threshold: 1)
+ ->paginated(false);
+ }
+
+ public function render()
+ {
+ return $this->getTableProperty();
+ }
+}
+
+beforeEach(function () {
+ Schema::create('collapse_header_actions_users', function (Blueprint $table) {
+ $table->id();
+ $table->string('name');
+ $table->timestamps();
+ });
+
+ CollapseHeaderActionsUser::create(['name' => 'Ada Lovelace']);
+});
+
+afterEach(fn () => Schema::dropIfExists('collapse_header_actions_users'));
+
+it('renders header actions as plain toolbar buttons by default', function () {
+ Livewire::test(CollapseHeaderActionsHost::class)
+ ->assertSee('header-action-create', escape: false)
+ ->assertSee('header-action-import', escape: false)
+ // No dropdown, and nothing wrapping the buttons for a breakpoint.
+ ->assertDontSee('table-header-actions-mobile', escape: false)
+ ->assertDontSee('action-group-trigger', escape: false)
+ ->assertDontSee('hidden sm:flex', escape: false);
+});
+
+it('collapses the header actions into one dropdown below the mobile breakpoint', function () {
+ Livewire::test(CollapseHeaderActionsHost::class)
+ ->set('collapse', true)
+ // Desktop half: the same buttons, now hidden below the breakpoint.
+ ->assertSee('hidden sm:flex', escape: false)
+ ->assertSee('header-action-create', escape: false)
+ // Mobile half: one trigger, shown only below the breakpoint.
+ ->assertSee('table-header-actions-mobile', escape: false)
+ ->assertSee('sm:hidden', escape: false)
+ ->assertSee('action-group-trigger', escape: false)
+ // …with both actions as menu rows wired to the record-less host methods.
+ ->assertSee('menu-action-create', escape: false)
+ ->assertSee('menu-action-import', escape: false)
+ // (escaped: the expression sits in a wire:click attribute)
+ ->assertSee("executeHeaderAction('create')")
+ // The confirmation action opens the modal instead of running.
+ ->assertSee("openHeaderActionModal('import')");
+});
+
+it('renders a lone surviving action as a button instead of a one-item menu', function () {
+ Livewire::test(CollapseHeaderActionsSingleHost::class)
+ // Collapse is on (threshold 1) but the viewer may run only one action,
+ // so the extra tap of a dropdown would buy nothing.
+ ->assertSee('table-header-actions-mobile', escape: false)
+ ->assertSee('header-action-create', escape: false)
+ ->assertDontSee('action-group-trigger', escape: false);
+});
+
+it('binds the header action keyboard shortcut once, on the button half only', function () {
+ $html = Livewire::test(CollapseHeaderActionsHost::class)->set('collapse', true)->html();
+
+ // A rendered shortcut is a *window* listener and both halves are in the
+ // document at every width; a second binding would run the action twice on
+ // one keypress.
+ expect(substr_count($html, 'keydown.c.window'))->toBe(1);
+});
diff --git a/packages/table/tests/Feature/TableSearchSyntaxTest.php b/packages/table/tests/Feature/TableSearchSyntaxTest.php
index 1fb598be..f62d8faf 100644
--- a/packages/table/tests/Feature/TableSearchSyntaxTest.php
+++ b/packages/table/tests/Feature/TableSearchSyntaxTest.php
@@ -490,3 +490,94 @@ public function render()
expect($records->pluck('reference')->all())->toBe(['8866 05']);
});
+
+// ── The declaration the search box cannot ask for ───────────
+//
+// `searchAs()` only says which comparisons a column can answer; without
+// `ranges()` none can be typed, so the table would come back empty with nothing
+// to explain it. That is refused at render, before anything is typed.
+
+class GuardedSearchHost extends Component
+{
+ use WithTable;
+
+ public string $type = 'code';
+
+ public bool $columnSearchable = true;
+
+ public bool $tableSearchable = true;
+
+ public bool $ranges = false;
+
+ public function table(Table $table): Table
+ {
+ $reference = TextColumn::make('reference')->searchAs($this->type);
+
+ $table
+ ->model(CodeOrder::class)
+ ->columns([$reference->searchable($this->columnSearchable)])
+ ->searchable($this->tableSearchable)
+ ->paginated(false);
+
+ return $this->ranges
+ ? $table->search(fn (SearchConfig $s) => $s->ranges())
+ : $table;
+ }
+
+ public function render()
+ {
+ return $this->getTableProperty();
+ }
+}
+
+/**
+ * The root cause of a render: the guard fires while the table is being built,
+ * so Blade wraps it before it reaches the caller.
+ */
+function guardFailure(array $params = []): Throwable
+{
+ try {
+ Livewire::test(GuardedSearchHost::class, $params);
+ } catch (Throwable $error) {
+ return $error->getPrevious() ?? $error;
+ }
+
+ throw new RuntimeException('The table rendered without refusing its search declaration.');
+}
+
+it('refuses a code column the search box cannot range over', function () {
+ $error = guardFailure();
+
+ expect($error)->toBeInstanceOf(TableConfigurationException::class)
+ ->and($error->getMessage())->toContain("Column [reference] declares searchAs('code')");
+});
+
+it('names tokenize() as well, since a code carries its series as a word', function () {
+ expect(guardFailure()->getMessage())->toContain('$s->tokenize()->ranges())');
+});
+
+it('asks only for ranges() where no series has to be rejoined', function () {
+ expect(guardFailure(['type' => 'numeric']))
+ ->getMessage()->toContain('$s->ranges())')
+ ->getMessage()->not->toContain('tokenize');
+});
+
+it('accepts the declaration once the table reads ranges', function () {
+ Livewire::test(GuardedSearchHost::class, ['ranges' => true])
+ ->assertOk();
+});
+
+it('leaves a text declaration alone, since it asserts no comparison', function () {
+ Livewire::test(GuardedSearchHost::class, ['type' => 'text'])
+ ->assertOk();
+});
+
+it('leaves a column that is not searchable alone', function () {
+ Livewire::test(GuardedSearchHost::class, ['columnSearchable' => false])
+ ->assertOk();
+});
+
+it('leaves a table with no search box alone', function () {
+ Livewire::test(GuardedSearchHost::class, ['tableSearchable' => false])
+ ->assertOk();
+});
diff --git a/packages/table/tests/Feature/WireStackScriptsTest.php b/packages/table/tests/Feature/WireStackScriptsTest.php
index 9591752d..f5735cf5 100644
--- a/packages/table/tests/Feature/WireStackScriptsTest.php
+++ b/packages/table/tests/Feature/WireStackScriptsTest.php
@@ -4,7 +4,6 @@
use Illuminate\Support\Facades\Blade;
use Illuminate\Support\Facades\Route;
-use NyonCode\WireTable\WireTableServiceProvider;
/**
* The defect this closes: an app navigates from a page with no table to a page
@@ -21,23 +20,26 @@
$response
->assertSee('No table here.')
// wireRecordSelection — the factory the table wrapper's x-data references.
- ->assertSee('/wire-table/assets/selection.js', false)
+ ->assertSee('/vendor/wire-table/wire-table-selection.js', false)
// wireRecordActions — row click/dblclick/context-menu triggers.
- ->assertSee('/wire-table/assets/records.js', false)
+ ->assertSee('/vendor/wire-table/wire-table-records.js', false)
// wireDropdown & friends, from the package below.
- ->assertSee('/wire-core/assets/dropdown.js', false)
+ ->assertSee('/vendor/wire-core/wire-core-dropdown.js', false)
->assertDontSee('toContain('/wire-table/assets/selection.js?id='.filemtime(
- WireTableServiceProvider::ASSETS_PATH.'/wire-table-selection.js'
+ ->toContain('/vendor/wire-table/wire-table-selection.js?id='.filemtime(
+ public_path('vendor/wire-table/wire-table-selection.js')
))
- ->toContain('/wire-table/assets/records.js?id='.filemtime(
- WireTableServiceProvider::ASSETS_PATH.'/wire-table-records.js'
+ ->toContain('/vendor/wire-table/wire-table-records.js?id='.filemtime(
+ public_path('vendor/wire-table/wire-table-records.js')
));
});
@@ -45,9 +47,15 @@
// The per-surface @assets partials still exist for apps without the directive;
// the directive must not turn into a second copy of them for apps with it.
//
- // Five, since the live-broadcast bridge joined them. It ships on every page
- // for the same reason the other two do: the factory the table's x-data
- // references has to exist before a wire:navigate visit renders the table, and
- // the page that visit is made *from* may have no table on it at all.
- expect(substr_count(Blade::render('@wireStackScripts'), ' |