Killing @if($isTailwind) in Blade: a theme-class seam for Livewire tables
DEV Community

Killing @if($isTailwind) in Blade: a theme-class seam for Livewire tables

The Problem

The package ships Tailwind and Bootstrap themes. Every Blade that needed styling did this:

@class([
    'px-3 py-2 ...' => $isTailwind,
    'p-2 border ...' => $isBootstrap,
])

Fine for two themes. But I was adding a Flux theme, and the honest question was: do I really want a third @elseif in every one of ~40 Blade partials? That's the smell. When adding a variant means editing every template, your variation axis is in the wrong place.

Analogy: this is a light switch wired directly to the bulb. Want a dimmer? You rewire every room. Better to run everything through one panel.

The Seam

One static class holds per-theme class strings, keyed by a dotted name:

class ThemeStyles
{
    protected static array $classes = [
        'tailwind' => [
            'table.wrapper' => 'shadow overflow-y-auto border-b ... sm:rounded-lg',
            // ...
        ],
        'flux' => [
            /* only the keys Flux overrides */
        ],
    ];

    public static function for(string $theme, string $key): string
    {
        /* ... */
    }
}

Blades stop branching and just ask for a key:

{{-- component trait: themeClasses() delegates to ThemeStyles::for() --}}
<td @class([$this->themeClasses('td.collapsed.base')])>

The trait behind it is three lines:

public function themeClasses(string $key): string
{
    return ThemeStyles::for($this->getTheme(), $key);
}

Two Design Choices Matter Here

Flux falls back to Tailwind. Flux is Tailwind-based - it only differs on a handful of keys (dropdown panels, pills, empty state). So for() resolves the theme's key, and if it's missing, falls back to the tailwind map. A new theme defines only its diffs, not the whole surface.

No closures in the map. Tempting to store fn () => ... for dynamic classes. Don't - closures can't be serialized, so php artisan config:cache (and Octane) chokes. Plain strings keep it cache-safe. Anything genuinely dynamic stays in the Blade around the seam, not inside the map.

Before After
Add a theme Edit ~40 Blades Add one map entry, override diffs only
Class source Scattered inline One file
Cache-safe n/a Yes (no closures)
Blade job Branch per theme Ask for a key

Don't Trust Yourself - Guard with Characterization Tests

Migrating 40 templates by hand is exactly where you silently drop a class and shift a border 1px. So before touching a Blade, I pinned its current output:

it('renders the collapsed cell identically after the seam migration', function () {
    $html = BooleanColumn::make('Active')
        ->render(/* ... */)
        ->toHtml();

    expect($html)->toContain('p-3 table-cell text-center');
});

A characterization test doesn't care whether the output is good - only that it didn't change. Migrate one Blade group, run the per-theme visual suite, confirm green, commit, next group. One slice at a time.

One real find along the way: an empty-string class key rendered subtly differently through @class() than an inlined blank - the kind of thing you only catch because the test compares exact output, not intent.

Takeaway

When adding a variant forces you to edit every file, extract the variation into a seam the files consult. For a Blade UI package that's a keyed class map plus a one-line themeClasses() accessor. Keep the map serializable so it survives config caching, and let characterization tests carry the risk of a large mechanical migration.

The payoff: the Flux theme landed as a set of key overrides, not a fourth copy of every template.

Comments

No comments yet. Start the discussion.