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
272 lines
14 KiB
PHP
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'],
|
|
]);
|