Files
livewire-material/tests/Feature/Components/SliderTest.php
T
Andreas Reinhold / reiniandClaude Sonnet 5 6897306e18 Prove no Tailwind utility class remains in resources/ or tests/
StylesheetsTest.php's new test runs DesignGuard's own family table (check
(i)) over resources/views, resources/js, src, tests/Browser and
tests/Feature — the guard turned on the package's own source, so the two
never drift apart. tests/Feature/DesignGuardTest.php and
tests/Fixtures/design-guard/ carry Tailwind on purpose and are left out.

The scan found Tailwind-shaped class strings used as caller classes in 21
test files (a component test proving a caller's class lands on the root or
a caller's size wins, mostly), left over from before Tailwind cleared the
whole stack: replaced with neutral application-style names (`app-hero-icon`)
or, where one already carries the right meaning, an `md-*` class
(`md-text-end`, `md-ink-warning`). BreakpointsTest.php's own heredoc probe —
which needs a genuine Tailwind-shaped breakpoint prefix to prove its
detection works — now builds that one string from a placeholder at runtime,
so its own source stays clean for this same scan.

Plan step 42 (Phase F), Part A.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
2026-09-15 09:32:36 +02:00

272 lines
14 KiB
PHP

<?php
use Illuminate\Support\MessageBag;
use Illuminate\Support\ViewErrorBag;
use Livewire\Component;
use Livewire\Livewire;
it('is a labelled native range input under a drawing only the script touches', function () {
$html = (string) $this->blade('<x-slider label="Volume" name="volume" value="40" :min="0" :max="200" :step="5" id="volume" />');
expect($html)
->toContain('<label for="volume" id="volume-label"')
->toContain('type="range"')
->toContain('id="volume"')
->toContain('name="volume"')
->toContain('min="0"')
->toContain('max="200"')
->toContain('step="5"')
->toContain('value="40"')
->toContain('x-data="materialSlider"')
->toContain('x-on:pointerdown="press($event)"')
->toContain('data-md-slider-control')
->toContain('<div data-md-slider-drawing wire:ignore aria-hidden="true">')
->toContain('data-md-slider-input="start"')
->not->toContain('class=')
->not->toContain('role="group"');
});
it('draws the first frame on the server: the track split around the handle, and the stop', function () {
$html = (string) $this->blade('<x-slider value="40" />');
expect($html)
->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
->toMatch('/data-md-slider-segment="start"\s+hidden/')
->toContain('style="left: calc(100% - 8px)"')
->toContain('<span data-md-slider-handle="start" style="left: calc(40% + 0px)">')
->toContain('<span data-md-slider-value>40</span>')
->toMatch('/<div\s+data-md-slider\s+data-md-size="xs"\s+data-md-color="primary"\s+data-md-value-label="drag"/');
});
it('draws M3\'s 44x48 value indicator and puts the stop on the inactive track', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
expect((string) $this->blade('<x-slider value="40" />'))->toMatch('/data-md-slider-stop="end"\s+style="left: calc\(100% - 8px\)"/')
->and($css)
->toMatch('/\[data-md-slider-value\] \{[^}]*min-width: var\(--md-sys-measurement-space600\);[^}]*height: 44px;[^}]*background-color: var\(--md-sys-color-inverse-surface\);[^}]*transform-origin: bottom;/')
->toMatch('/:is\(\[data-md-slider-tick\], \[data-md-slider-stop\]\) \{[^}]*background-color: var\(--slider-on-inactive\);/')
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/all.css'))->toContain("@import './components/slider.css';");
});
it('snaps and clamps the value to the step grid', function (string $template, string $value) {
expect((string) $this->blade($template))->toContain("value=\"{$value}\"");
})->with([
'between steps' => ['<x-slider value="23" :step="5" />', '25'],
'below the minimum' => ['<x-slider :value="-20" />', '0'],
'past the last step' => ['<x-slider value="10" :max="10" :step="3" />', '9'],
'a fraction' => ['<x-slider value="0.34" :max="1" step="0.1" />', '0.3'],
'continuous' => ['<x-slider value="12.345" step="any" />', '12.345'],
'centred without a value' => ['<x-slider :min="-50" :max="50" centered />', '0'],
]);
it('binds wire:model and x-model to the input as numbers, and names it after the property', function () {
expect((string) $this->blade('<x-slider wire:model.live.debounce.250ms="volume" />'))
->toContain('wire:model.number.live.debounce.250ms="volume"')
->toContain('name="volume"');
expect((string) $this->blade('<x-slider x-model="volume" name="volume" x-on:change="saved = true" />'))
->toContain('x-model.number="volume"')
->toContain('x-on:change="saved = true"');
expect((string) $this->blade('<x-slider x-model.number="volume" />'))->toContain('x-model.number="volume"');
});
it('gives a range two inputs bound to the ends of an array, in a named group', function () {
$html = (string) $this->blade('<x-slider label="Price" wire:model="price" range :value="[80, 20]" id="price" />');
expect($html)
->toContain('role="group"')
->toContain('aria-labelledby="price-label"')
->toContain('wire:model.number="price.0"')
->toContain('wire:model.number="price.1"')
->toContain('aria-label="Range start"')
->toContain('aria-label="Range end"')
->toContain('id="price-end"')
->toContain('data-md-slider-handle="end"')
->toContain('data-md-slider-input="end"')
->not->toContain('<label for=');
expect(substr_count($html, 'name="price[]"'))->toBe(2)
// Out of order, the ends are sorted, as Compose sorts them.
->and(strpos($html, 'value="20"'))->toBeLessThan(strpos($html, 'value="80"'));
expect((string) $this->blade('<x-slider x-model="price" range />'))
->toContain('x-model.number="price[0]"')
->toContain('x-model.number="price[1]"');
});
it('draws the value of the bound Livewire property', function () {
$component = new class extends Component
{
public int $volume = 70;
/** @var array<int, int> */
public array $price = [100, 400];
public function render(): string
{
return <<<'BLADE'
<div>
<x-slider wire:model.live="volume" />
<x-slider wire:model="price" range :max="500" />
</div>
BLADE;
}
};
Livewire::test($component)
->assertSeeHtml('value="70"')
->assertSeeHtml('style="left: calc(70% + 0px)"')
->assertSeeHtml('value="100"')
->assertSeeHtml('value="400"');
});
it('replaces the hint with the validation message and marks the input invalid', function () {
$errors = (new ViewErrorBag)->put('default', new MessageBag(['volume' => ['Too loud.'], 'price.1' => ['Too expensive.']]));
$this->withViewErrors([]);
view()->share('errors', $errors);
expect((string) $this->blade('<x-slider name="volume" hint="Quietly" id="volume" />'))
->toContain('Too loud.')
->toContain('<div id="volume-hint" data-md-slider-errors>')
->toContain('aria-invalid="true"')
->toContain('aria-describedby="volume-hint"')
->not->toContain('Quietly');
expect((string) $this->blade('<x-slider name="price" range hint="Per night" />'))
->toContain('Too expensive.')
->not->toContain('Per night');
expect((string) $this->blade('<x-slider name="balance" hint="Centre is even" />'))
->toMatch('/data-md-slider-hint>Centre is even<\/p>/')
->not->toContain('aria-invalid');
});
it('sizes the track and the handle by Expressive\'s tokens', function (string $size, string $track, string $handle, string $across) {
expect((string) $this->blade('<x-slider :size="$size" />', ['size' => $size]))
->toContain("data-md-size=\"{$size}\"");
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
$block = $size === 'xs'
? '/\[data-md-slider\] \{[^}]*/'
: '/\[data-md-slider\]\[data-md-size=\''.$size.'\'\] \{[^}]*/';
preg_match($block, $css, $rule);
foreach (['--slider-across' => $across, '--slider-track' => $track, '--slider-handle' => $handle] as $property => $value) {
if ($size === 'sm' && $property !== '--slider-track') {
// sm keeps xs's handle and height.
continue;
}
expect($rule[0])->toContain("{$property}: {$value};");
}
// A focused handle is 6px shorter, so its ring stays clear of the label.
expect($css)->toContain('height: calc(var(--slider-handle) - 6px);');
})->with([
'xs' => ['xs', '16px', '44px', '48px'],
'sm' => ['sm', '24px', '44px', '48px'],
'md' => ['md', '40px', '52px', '52px'],
'lg' => ['lg', '56px', '68px', '68px'],
'xl' => ['xl', '96px', '108px', '108px'],
]);
it('insets an icon in the track from md, but not on a small, range or centred slider', function () {
expect((string) $this->blade('<x-slider size="md" icon="volume_up" value="60" />'))
->toMatch('/data-md-slider-icon\s+data-md-active/')
->toContain('--md-icon-size: 24px');
expect((string) $this->blade('<x-slider size="xl" icon="volume_up" value="2" />'))
->toMatch('/data-md-slider-icon\s+style=/')
->toContain('--md-icon-size: 32px');
foreach (['<x-slider size="sm" icon="volume_up" />', '<x-slider size="lg" icon="volume_up" range />', '<x-slider size="lg" icon="volume_up" centered />'] as $template) {
expect((string) $this->blade($template))->not->toContain('data-md-slider-icon');
}
});
it('fills a centred slider from the middle', function () {
expect((string) $this->blade('<x-slider :min="-50" :max="50" value="25" centered />'))
->toContain('data-md-centered')
->toMatch('/data-md-slider-segment="start"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
->toMatch('/data-md-slider-segment="active"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/')
->toMatch('/data-md-slider-segment="end"\s+style="left: calc\(75% \+ 8px\)/');
});
it('marks every step when asked, and no more than 200 of them', function () {
$html = (string) $this->blade('<x-slider ticks :max="10" value="5" />');
expect(substr_count($html, 'data-md-slider-tick="'))->toBe(11)
->and($html)->toContain('data-md-slider-tick="0.5"')
->and($html)->toMatch('/data-md-slider-tick="0.2"\s+data-md-active/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
->toMatch('/\[data-md-slider-tick\]\[data-md-active\] \{\s*background-color: var\(--slider-on-active\);/');
expect((string) $this->blade('<x-slider ticks :max="1000" />'))->not->toContain('data-md-slider-tick=')
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-md-slider-tick=')
->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-md-slider-tick=');
});
it('shows the value label on drag, always, or never', function () {
expect((string) $this->blade('<x-slider />'))->toContain('data-md-value-label="drag"')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css'))
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='drag'\\] \\[data-md-slider-value\\] \\{\\s*opacity: 0;\\s*scale: 0.75;/")
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\] \\[data-md-slider-control\\] \\{\\s*margin-top: var\\(--md-sys-measurement-space600\\);/");
expect((string) $this->blade('<x-slider value-label="always" value="30" />'))
->toContain('data-md-value-label="always"')
->toContain('<span data-md-slider-value>30</span>');
expect((string) $this->blade('<x-slider value-label="never" />'))->not->toContain('data-md-slider-value');
});
it('stands a slider up on request, turning the drawing a quarter', function () {
$html = (string) $this->blade('<x-slider label="Volume" orientation="vertical" value="40" class="app-volume-slider" />');
expect($html)
->toContain('data-md-orientation="vertical"')
->toContain('aria-orientation="vertical"')
->toContain('class="app-volume-slider"');
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
expect($css)
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*width: var\\(--slider-across\\);[^}]*height: 192px;[^}]*container-type: size;/")
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-drawing\\] \\{[^}]*width: calc\\(100cqh - var\\(--md-sys-measurement-space50\\)\\);[^}]*height: 100cqw;[^}]*rotate: -90deg;/")
// The value label is turned back, so it lands beside the handle and reads across.
->toMatch("/\\[data-md-slider\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-value\\] \\{[^}]*rotate: 90deg;/")
->toMatch("/\\[data-md-slider\\]\\[data-md-value-label='always'\\]\\[data-md-orientation='vertical'\\] \\[data-md-slider-control\\] \\{[^}]*margin-inline-start: var\\(--md-sys-measurement-space600\\);/");
});
it('keeps a range slider horizontal, as M3 asks', function () {
expect((string) $this->blade('<x-slider label="Price" range orientation="vertical" />'))
->not->toContain('data-md-orientation')
->not->toContain('aria-orientation');
});
it('draws in the colour and its container, and greys out when disabled', function (string $color, string $active, string $inactive) {
expect((string) $this->blade('<x-slider :color="$color" value="50" disabled />', ['color' => $color]))
->toContain('data-md-color="'.($color === 'pink' ? 'primary' : $color).'"')
->toMatch('/<input[^>]*\sdisabled[\s>]/');
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/slider.css');
if ($color === 'primary' || $color === 'pink') {
expect($css)->toMatch("/\\[data-md-slider\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
} else {
expect($css)->toMatch("/\\[data-md-slider\\]\\[data-md-color='{$color}'\\] \\{[^}]*--slider-active: var\\(--md-sys-color-{$active}\\);[^}]*--slider-inactive: var\\(--md-sys-color-{$inactive}\\);/");
}
expect($css)->toMatch('/\[data-md-slider\]:has\(\[data-md-slider-input\]:disabled\) \{[^}]*--slider-active: var\(--slider-disabled-active\);[^}]*--slider-inactive: var\(--slider-disabled-inactive\);/');
})->with([
'primary' => ['primary', 'primary', 'secondary-container'],
'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
'error' => ['error', 'error', 'error-container'],
'unknown' => ['pink', 'primary', 'secondary-container'],
]);