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
269 lines
13 KiB
PHP
269 lines
13 KiB
PHP
<?php
|
||
|
||
use Carbon\CarbonImmutable;
|
||
use Livewire\Component;
|
||
use Livewire\Livewire;
|
||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||
|
||
/**
|
||
* The JSON config handed to `materialDatepicker(…)`, decoded.
|
||
*
|
||
* @return array<string, mixed>
|
||
*/
|
||
function datepickerConfig(string $html): array
|
||
{
|
||
preg_match("/\.\.\.JSON\.parse\('(.+?)'\)/", $html, $match);
|
||
|
||
return json_decode(json_decode('"'.$match[1].'"'), true, flags: JSON_THROW_ON_ERROR);
|
||
}
|
||
|
||
it('draws a docked date field whose combobox controls a popover picker', function () {
|
||
$html = (string) $this->blade('<x-datepicker id="expires" label="Expires on" hint="Type a date or pick one" />');
|
||
|
||
expect($html)
|
||
->toContain('data-md-datepicker')
|
||
->toContain('<label for="expires" data-md-field-label>Expires on</label>')
|
||
->toContain('role="combobox"')
|
||
->toContain('aria-haspopup="dialog"')
|
||
->toContain('aria-controls="expires-picker"')
|
||
->toContain('aria-expanded="false"')
|
||
->toContain('aria-describedby="expires-support"')
|
||
->toContain('Type a date or pick one')
|
||
->toContain('<dialog')
|
||
->toContain('id="expires-picker"')
|
||
->toContain('popover="manual"')
|
||
->toContain('wire:ignore')
|
||
->toContain('aria-label="Select date"')
|
||
->toContain('role="grid"')
|
||
->toContain('data-md-datepicker-toggle')
|
||
->toContain('x-modelable="value"')
|
||
->toContain('x-on:input="typeInField()"')
|
||
->not->toMatch('/<input[^>]*\sreadonly[\s>]/')
|
||
->not->toContain('aria-invalid="true"')
|
||
->and(datepickerConfig($html))->toMatchArray(['mode' => 'docked', 'range' => false, 'min' => null, 'max' => null, 'locale' => 'en'])
|
||
->and($html)->toContain('style="anchor-name: --material-datepicker-expires"')
|
||
->toContain('style="position-anchor: --material-datepicker-expires"');
|
||
});
|
||
|
||
it('makes the field a read-only trigger for the modal and modal input pickers', function (string $mode) {
|
||
$html = (string) $this->blade('<x-datepicker label="Birthday" :mode="$mode" />', ['mode' => $mode]);
|
||
|
||
expect($html)
|
||
->toMatch('/<input[^>]*\sreadonly[\s>]/')
|
||
->toContain('x-on:keydown.space.prevent="show()"')
|
||
->not->toContain('x-on:input="typeInField()"')
|
||
->and(datepickerConfig($html)['mode'])->toBe($mode);
|
||
})->with(['modal', 'input']);
|
||
|
||
it('gives a range picker M3\'s full-screen presentation, with an app bar and Save', function () {
|
||
$html = (string) $this->blade('<x-datepicker id="trip" label="Trip" range mode="modal" />');
|
||
|
||
expect($html)
|
||
->toContain('data-md-datepicker-full')
|
||
->toContain('x-show="presentation === \'full\'"')
|
||
->toContain('data-md-datepicker-app-bar')
|
||
->toContain('data-md-datepicker-close')
|
||
->toContain('data-md-datepicker-save')
|
||
->toContain('Save')
|
||
->toContain('aria-label="Select dates"')
|
||
// The months come from one list of rows, each headed by its label.
|
||
->toContain('x-for="row in rows"')
|
||
->toContain('data-md-datepicker-month-label')
|
||
// The action row is the modal's; the full-screen picker confirms from its app bar.
|
||
->toContain('x-show="presentation !== \'full\' && (view === \'days\' || typing || presentation === \'modal\')"')
|
||
->toContain('x-on:scroll="extendMonths($event)"');
|
||
|
||
expect(datepickerConfig($html))->toMatchArray(['range' => true, 'mode' => 'modal']);
|
||
});
|
||
|
||
it('leaves the docked and single-date pickers as they were', function () {
|
||
// Only the presentation is new: a single date never reaches it, whatever the window.
|
||
expect(file_get_contents(__DIR__.'/../../../resources/js/datepicker.js'))
|
||
->toContain("this.presentation = config.range && this.compact ? 'full' : config.mode === 'docked' && !this.compact ? 'docked' : 'modal'");
|
||
|
||
$html = (string) $this->blade('<x-datepicker id="expires" label="Expires on" />');
|
||
|
||
expect($html)
|
||
->toContain('data-md-datepicker-nav data-md-docked')
|
||
->toContain('x-show="presentation === \'docked\'"')
|
||
->toContain('data-md-datepicker-menu-button="months"')
|
||
->toContain('x-show="presentation === \'modal\'"');
|
||
});
|
||
|
||
it('names each dropdown so Shift+M and Shift+Y can reach it', function () {
|
||
$html = (string) $this->blade('<x-datepicker label="Birthday" />');
|
||
|
||
expect($html)
|
||
->toContain('data-md-datepicker-menu-button="months"')
|
||
->toContain('data-md-datepicker-menu-button="years"');
|
||
});
|
||
|
||
it('draws the year, the menu buttons and the list options from the interaction classes', function () {
|
||
$html = (string) $this->blade('<x-datepicker id="expires" label="Expires on" />');
|
||
$css = ComponentStylesheet::read('datepicker');
|
||
|
||
expect(preg_match_all('/class="md-state-layer md-focus-ring"\s+data-md-datepicker-menu-button=/', $html))->toBe(3)
|
||
->and(preg_match_all('/class="md-state-layer md-focus-ring"\s+data-md-datepicker-year\b/', $html))->toBe(1)
|
||
->and(preg_match_all('/class="md-state-layer md-focus-ring"\s+data-md-datepicker-option\b/', $html))->toBe(2)
|
||
->and($html)->toContain('<span id="expires-month" class="md-visually-hidden" aria-live="polite" x-text="monthYear"></span>')
|
||
// Nothing in the stylesheet takes back what the classes draw…
|
||
->and($css->declarations('[data-md-datepicker-menu-button]'))->not->toHaveKeys(['position', 'isolation', 'outline'])
|
||
->and($css->declarations('[data-md-datepicker-year]'))->not->toHaveKeys(['position', 'isolation', 'outline'])
|
||
->and($css->declarations('[data-md-datepicker-option]'))->not->toHaveKeys(['position', 'isolation', 'outline'])
|
||
// …but the option's ring, which sits inside its full-width row.
|
||
->and($css->declarations('[data-md-datepicker-option]:focus-visible'))->toBe(['outline-offset' => '-3px']);
|
||
});
|
||
|
||
it('draws the day\'s state layer on its indicator at M3\'s state opacities, and none on a disabled day', function () {
|
||
$css = ComponentStylesheet::read('datepicker');
|
||
|
||
expect($css->declarations('[data-md-datepicker-day] > span::before'))->toMatchArray(['background-color' => 'var(--datepicker-layer)', 'opacity' => '0', 'z-index' => '-1'])
|
||
->and($css->declarations('[data-md-datepicker-day] > span'))->toMatchArray(['width' => '40px', 'height' => '40px', 'isolation' => 'isolate'])
|
||
->and($css->declarations('[data-md-datepicker-day]:hover > span::before', ['@media (hover: hover)']))->toBe(['opacity' => 'var(--md-sys-state-hover-state-layer-opacity)'])
|
||
->and($css->declarations('[data-md-datepicker-day]:focus-visible > span::before'))->toBe(['opacity' => 'var(--md-sys-state-focus-state-layer-opacity)'])
|
||
->and($css->declarations('[data-md-datepicker-day]:active > span::before'))->toBe(['opacity' => 'var(--md-sys-state-pressed-state-layer-opacity)'])
|
||
->and($css->declarations("[data-md-datepicker-day]:is([aria-disabled='true'], [data-md-blank]) > span::before"))->toBe(['display' => 'none'])
|
||
->and($css->declarations('[data-md-datepicker-day]:focus-visible > span'))->toBe(['outline' => '3px solid var(--md-sys-color-secondary)', 'outline-offset' => '2px'])
|
||
->and($css->declarations('[data-md-datepicker-day]'))->toMatchArray(['height' => '48px']);
|
||
});
|
||
|
||
it('falls back to the docked picker for an unknown mode', function () {
|
||
expect(datepickerConfig((string) $this->blade('<x-datepicker label="Date" mode="wheel" />'))['mode'])->toBe('docked');
|
||
});
|
||
|
||
it('hands min, max and the application locale to the picker as Y-m-d', function () {
|
||
app()->setLocale('de_CH');
|
||
|
||
$config = datepickerConfig((string) $this->blade(
|
||
'<x-datepicker label="Date" :min="$min" max="2026-12-31 23:59:00" />',
|
||
['min' => CarbonImmutable::parse('2026-09-13 08:30')],
|
||
));
|
||
|
||
expect($config)->toMatchArray(['min' => '2026-09-13', 'max' => '2026-12-31', 'locale' => 'de-CH'])
|
||
->and($config['strings']['pattern'])->toBe('Date does not match expected pattern: :pattern')
|
||
->and(datepickerConfig((string) $this->blade('<x-datepicker label="Date" min="2026-02-30" max="soon" />')))
|
||
->toMatchArray(['min' => null, 'max' => null]);
|
||
});
|
||
|
||
it('hands a chosen first day of the week and format to the picker, and ignores values that are neither', function () {
|
||
app()->setLocale('de');
|
||
|
||
$html = (string) $this->blade('<x-datepicker label="Date" week-start="0" format="yyyy-MM-dd" value="2026-09-13" />');
|
||
|
||
expect(datepickerConfig($html))->toMatchArray(['weekStart' => 0, 'format' => 'yyyy-MM-dd', 'locale' => 'de'])
|
||
->and($html)->toContain('value="2026-09-13"')
|
||
->and(datepickerConfig((string) $this->blade('<x-datepicker label="Date" :week-start="6" format="MM/dd/yyyy" />')))
|
||
->toMatchArray(['weekStart' => 6, 'format' => 'MM/dd/yyyy'])
|
||
->and((string) $this->blade('<x-datepicker label="Trip" range format="dd/MM/yyyy" :value="[\'start\' => \'2026-09-13\', \'end\' => \'2026-09-20\']" />'))
|
||
->toContain('value="13/09/2026 – 20/09/2026"');
|
||
|
||
foreach (['week-start="7"', 'week-start="-1"', 'week-start="monday"', 'week-start', 'format="d.M.yy"', 'format="dd.MM/yyyy"', 'format="dd.dd.yyyy"', 'format="yyyy-mm-dd"'] as $attribute) {
|
||
expect(datepickerConfig((string) $this->blade("<x-datepicker label=\"Date\" {$attribute} />")))
|
||
->toMatchArray(['weekStart' => null, 'format' => null]);
|
||
}
|
||
|
||
expect(datepickerConfig((string) $this->blade('<x-datepicker label="Date" />')))->toMatchArray(['weekStart' => null, 'format' => null])
|
||
->and((string) $this->blade('<x-datepicker label="Date" format="dd/MM/y" value="2026-09-13" />'))->toContain('value="13.09.2026"');
|
||
});
|
||
|
||
it('shows the value in the locale\'s numeric format before Alpine starts', function () {
|
||
expect((string) $this->blade('<x-datepicker label="Date" value="2026-09-13" name="expires" />'))
|
||
->toContain('value="09/13/2026"')
|
||
->toContain('<input type="hidden" name="expires" value="2026-09-13"');
|
||
|
||
app()->setLocale('de');
|
||
|
||
expect((string) $this->blade('<x-datepicker label="Trip" range :value="[\'start\' => \'2026-09-13\', \'end\' => \'2026-09-20\']" name="trip" />'))
|
||
->toContain('value="13.09.2026 – 20.09.2026"')
|
||
->toContain('name="trip[start]" value="2026-09-13"')
|
||
->toContain('name="trip[end]" value="2026-09-20"')
|
||
->toContain('aria-label="Select dates"');
|
||
});
|
||
|
||
it('puts class on the root and every other attribute on the text field', function () {
|
||
$html = (string) $this->blade('<x-datepicker id="due" label="Due" class="app-due-field" required disabled variant="filled" size="sm" />');
|
||
|
||
expect($html)
|
||
->toContain('class="app-due-field"')
|
||
->toContain('data-md-variant="filled"')
|
||
->toContain('data-md-size="sm"')
|
||
->toMatch('/<input\s[^>]*required[^>]*id="due"/s')
|
||
->toMatch('/<input\s[^>]*disabled[^>]*id="due"/s')
|
||
->and(datepickerConfig($html)['disabled'])->toBeTrue();
|
||
});
|
||
|
||
it('entangles the value with its Livewire property and renders it as the property says', function () {
|
||
Livewire::component('datepicker-probe', new class extends Component
|
||
{
|
||
public ?string $expires = '2026-09-13';
|
||
|
||
/** @var array{start: ?string, end: ?string} */
|
||
public array $trip = ['start' => '2026-09-01', 'end' => null];
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div>
|
||
<x-datepicker label="Expires" wire:model.live="expires" />
|
||
<x-datepicker label="Trip" wire:model="trip" range mode="modal" />
|
||
</div>
|
||
BLADE;
|
||
}
|
||
});
|
||
|
||
$html = Livewire::test('datepicker-probe')->html();
|
||
|
||
expect($html)
|
||
->toContain(".entangle('expires').live")
|
||
->toContain(".entangle('trip')")
|
||
->not->toContain('x-modelable')
|
||
->not->toContain('wire:model')
|
||
->toContain('value="09/13/2026"')
|
||
->toContain('value="09/01/2026 – "');
|
||
});
|
||
|
||
it('replaces the hint with the errors for the property and its start and end', function () {
|
||
Livewire::component('datepicker-errors', new class extends Component
|
||
{
|
||
public ?string $expires = null;
|
||
|
||
/** @var array{start: ?string, end: ?string} */
|
||
public array $trip = ['start' => null, 'end' => null];
|
||
|
||
public function mount(): void
|
||
{
|
||
$this->addError('expires', 'Choose a date in the future.');
|
||
$this->addError('trip.end', 'The end must follow the start.');
|
||
}
|
||
|
||
public function render(): string
|
||
{
|
||
return <<<'BLADE'
|
||
<div>
|
||
<x-datepicker id="expires" label="Expires" hint="Pick a day" wire:model="expires" />
|
||
<x-datepicker id="trip" label="Trip" wire:model="trip" range />
|
||
</div>
|
||
BLADE;
|
||
}
|
||
});
|
||
|
||
$html = Livewire::test('datepicker-errors')->html();
|
||
|
||
expect($html)
|
||
->toContain('Choose a date in the future.')
|
||
->toContain('The end must follow the start.')
|
||
->not->toContain('Pick a day')
|
||
->toContain('aria-invalid="true"')
|
||
->toContain('data-md-datepicker-error')
|
||
->and(substr_count($html, 'data-md-invalid=""'))->toBe(2);
|
||
});
|
||
|
||
it('offers a clear button on request', function () {
|
||
expect((string) $this->blade('<x-datepicker label="Expires" clearable />'))
|
||
->toContain('data-md-field-clear')
|
||
->toContain('x-on:click="clear()"')
|
||
->toContain('aria-label="Clear"')
|
||
->and((string) $this->blade('<x-datepicker label="Expires" />'))
|
||
->not->toContain('data-md-field-clear');
|
||
});
|