Merge branch 'worktree-agent-a41c756369d016a29'

This commit is contained in:
Andreas Reinhold / reini
2026-09-14 15:40:58 +02:00
67 changed files with 3434 additions and 1497 deletions
+102 -81
View File
@@ -16,42 +16,56 @@ function chipTag(string $blade, string $tag = '[a-z]+'): string
it('is an outlined assist chip, a button, by default', function () {
$html = (string) $this->blade('<x-chip label="Add to calendar" icon="event" icon-right="arrow_drop_down" />');
expect(chipTag('<x-chip label="Add to calendar" icon="event" />', 'button'))
->toContain('data-chip="assist"')
->toContain('type="button"')
->toContain('h-8')
->toContain('rounded-corner-sm')
->toContain('type-label-lg')
->toContain('border-outline-variant text-on-surface')
->toContain('ps-1.75 pe-3.75')
expect(chipTag('<x-chip label="Add to calendar" icon="event" class="me-2" />', 'button'))
->toBe('<button data-md-chip="assist" data-md-icon-start="" type="button" class="me-2">')
->and(chipTag('<x-chip label="Add to calendar" icon-right="arrow_drop_down" />', 'button'))
->toContain('data-md-icon-end=""')
->not->toContain('data-md-icon-start')
->and($html)
->toContain('me-2 size-4.5 text-primary')
->toContain('ms-2 size-4.5 text-primary')
->toContain('Add to calendar');
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-icon)[^>]*>/')
->toMatch('/<svg(?=[^>]*--md-icon-size: 18px)(?=[^>]*data-md-chip-trailing)[^>]*>/')
->toContain('<span data-md-chip-label>Add to calendar</span>')
->not->toContain('class=');
});
it('draws a chip at Compose\'s size, corner, type and padding from its stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css');
expect($css)->toContain('@layer material.components')
->toContain("@import './icon.css';")
->toMatch('/\[data-md-chip\] \{[^}]*height: 32px;[^}]*border-radius: var\(--md-sys-shape-corner-sm\);[^}]*font: var\(--md-sys-typescale-label-lg\);/')
// 16px beside a label and 8px beside an icon, each 1px short for the border.
->toContain('padding-inline: 15px;')
->toContain('padding-inline-start: 7px;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/chip.css';");
});
it('pads a chip without icons by 16px on both sides', function () {
expect(chipTag('<x-chip label="Go" />', 'button'))->toContain('ps-3.75 pe-3.75');
expect(chipTag('<x-chip label="Go" />', 'button'))
->not->toContain('data-md-icon-start')
->not->toContain('data-md-icon-end');
});
it('falls back to an assist chip for a type it does not know', function () {
expect(chipTag('<x-chip type="choice" label="Go" />', 'button'))->toContain('data-chip="assist"');
expect(chipTag('<x-chip type="choice" label="Go" />', 'button'))->toContain('data-md-chip="assist"');
});
it('draws an elevated chip on surface-container-low at elevation 1', function () {
expect(chipTag('<x-chip label="Share" elevated />', 'button'))
->toContain('border-transparent bg-surface-container-low text-on-surface shadow-elevation-1 hover:shadow-elevation-2')
->not->toContain('border-outline-variant');
expect(chipTag('<x-chip label="Share" elevated />', 'button'))->toContain('data-md-elevated=""')
->and(chipTag('<x-chip label="Share" />', 'button'))->not->toContain('data-md-elevated')
// Input chips are flat only.
->and(chipTag('<x-chip type="input" label="Anna" elevated />', 'span'))->not->toContain('data-md-elevated')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
->toMatch('/\[data-md-chip\]\[data-md-elevated\] \{[^}]*background-color: var\(--md-sys-color-surface-container-low\);[^}]*box-shadow: var\(--md-sys-elevation-1\);/');
});
it('greys a disabled chip, flat or elevated', function () {
expect(chipTag('<x-chip label="Share" icon="share" disabled />', 'button'))
->toContain('disabled')
->toContain('border-on-surface/12 text-on-surface/38')
->toContain('data-md-disabled=""')
->toMatch('/\sdisabled="disabled"/')
->and(chipTag('<x-chip label="Share" elevated disabled />', 'button'))
->toContain('bg-on-surface/12 text-on-surface/38')
->and((string) $this->blade('<x-chip label="Share" icon="share" disabled />'))
->not->toContain('text-primary');
->toContain('data-md-elevated=""')
->toContain('data-md-disabled=""');
});
it('links with wire:navigate, and a disabled link leaves the tab order', function () {
@@ -66,15 +80,14 @@ it('links with wire:navigate, and a disabled link leaves the tab order', functio
->and(chipTag('<x-chip label="Directions" link="/directions" disabled />', 'a'))
->toContain('aria-disabled="true"')
->toContain('tabindex="-1"')
->toContain('pointer-events-none');
->toContain('data-md-disabled=""');
});
it('draws a suggestion chip in on-surface-variant', function () {
expect(chipTag('<x-chip type="suggestion" label="Sounds good" />', 'button'))
->toContain('data-chip="suggestion"')
->toContain('border-outline-variant text-on-surface-variant')
->toContain('data-md-chip="suggestion"')
->and(chipTag('<x-chip type="suggestion" label="Sounds good" elevated />', 'button'))
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1');
->toContain('data-md-elevated=""');
});
it('attaches a plain tooltip', function () {
@@ -90,37 +103,31 @@ it('makes a filter chip without a model a toggle button the caller owns', functi
$html = (string) $this->blade('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />');
expect(chipTag('<x-chip type="filter" label="Starred" :selected="true" wire:click="toggleStarred" />', 'button'))
->toContain('data-chip="filter"')
->toContain('data-md-chip="filter"')
->toContain('aria-pressed="true"')
->toContain('wire:click="toggleStarred"')
->toContain('aria-pressed:bg-secondary-container aria-pressed:text-on-secondary-container')
->and(chipTag('<x-chip type="filter" label="Starred" />', 'button'))
->toContain('aria-pressed="false"')
->and($html)
->not->toContain('type="checkbox"')
->toContain('group-aria-pressed/chip:w-4.5')
->toContain('data-chip-check');
->toContain('<span data-md-chip-check-slot>')
->toContain('data-md-chip-check');
});
it('makes a bound filter chip a native checkbox under the chip', function () {
$html = (string) $this->blade('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />');
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" wire:key="photos" />', 'label'))
->toContain('data-chip="filter"')
->toContain('me-4')
->toContain('wire:key="photos"')
->toContain('has-checked:border-transparent has-checked:bg-secondary-container has-checked:text-on-secondary-container')
->toContain('has-focus-visible:outline-3')
->not->toContain('x-model')
->toBe('<label data-md-chip="filter" class="me-4" wire:key="photos">')
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="me-4" />', 'input'))
->toContain('type="checkbox"')
->toContain('value="photos"')
->toContain('x-model="kinds"')
->toContain('sr-only')
->toContain('class="md-visually-hidden"')
->not->toContain('me-4')
->not->toContain('checked')
->and($html)
->toContain('group-has-checked/chip:w-4.5');
->toContain('data-md-chip-check');
});
it('submits a named filter chip, checked when selected, and greys a disabled one', function () {
@@ -130,48 +137,47 @@ it('submits a named filter chip, checked when selected, and greys a disabled one
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'input'))
->toContain('disabled')
->and(chipTag('<x-chip type="filter" label="Starred" name="starred" disabled />', 'label'))
->toContain('border-on-surface/12 text-on-surface/38 has-checked:border-transparent has-checked:bg-on-surface/12')
->toContain('cursor-not-allowed');
->toContain('data-md-disabled=""');
});
it('swaps a filter chip\'s icon for the check when selected', function () {
$html = (string) $this->blade('<x-chip type="filter" label="Starred" icon="star" name="starred" elevated />');
expect($html)
->toContain('group-has-checked/chip:opacity-0')
->toContain('text-primary')
->toContain('bg-surface-container-low text-on-surface-variant shadow-elevation-1')
->not->toContain('w-0')
->and(substr_count($html, '<svg'))->toBe(2);
->toMatch('/<span data-md-chip-check-slot>\s*<svg[^>]*data-md-chip-icon[^>]*>.*?<\/svg>\s*<svg[^>]*data-md-chip-check/s')
->toContain('data-md-elevated=""')
->and(substr_count($html, '<svg'))->toBe(2)
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
->toContain("[data-md-chip]:is(:has(:checked), [aria-pressed='true']) [data-md-chip-check-slot] [data-md-chip-icon] {");
});
it('draws an input chip with an avatar, an icon or neither', function () {
expect(chipTag('<x-chip type="input" label="Anna" avatar="AM" />', 'span'))
->toContain('data-chip="input"')
->toContain('border-outline-variant text-on-surface-variant')
->toContain('data-md-chip="input"')
->toContain('data-md-avatar=""')
->not->toContain('x-data')
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="AM" />'))
->toContain('ps-0.75 pe-2.75')
->toContain('size-6 shrink-0 place-items-center rounded-corner-full bg-primary-container')
->toContain('>AM</span>')
->toContain('<span aria-hidden="true" data-md-chip-avatar>AM</span>')
->not->toContain('<button')
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="/avatars/anna.jpg" />'))
->toContain('<img src="/avatars/anna.jpg" alt=""')
->and((string) $this->blade('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />'))
->toContain('ps-1.75 pe-2.75');
->toContain('<img src="/avatars/anna.jpg" alt="" data-md-chip-avatar />')
->and(chipTag('<x-chip type="input" label="report.pdf" icon="picture_as_pdf" />', 'span'))
->toContain('data-md-icon-start=""')
->not->toContain('data-md-avatar');
});
it('makes an input chip a button when it has its own action, and selects it', function () {
$html = (string) $this->blade('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />');
expect(chipTag('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />', 'button'))
->toContain('data-chip-action')
->toContain('data-md-chip-action')
->toContain('aria-pressed="true"')
->toContain('wire:click="open(1)"')
->and(chipTag('<x-chip type="input" label="Anna" :selected="true" />', 'span'))
->toContain('border-transparent bg-secondary-container text-on-secondary-container')
->and($html)
->toContain('me-2 size-4.5 text-primary');
->toContain('data-md-selected=""')
// An action of its own lights the leading icon on hover, focus and press.
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'span'))
->toContain('data-md-actionable=""')
->and(chipTag('<x-chip type="input" label="Anna" icon="person" />', 'span'))
->not->toContain('data-md-actionable');
});
it('gives a removable input chip a named remove button that calls wire:remove', function () {
@@ -180,15 +186,16 @@ it('gives a removable input chip a named remove button that calls wire:remove',
expect(chipTag('<x-chip type="input" label="anna@example.com" removable wire:remove="removeRecipient(3)" />', 'span'))
->toContain('x-data="materialChip"')
->toContain('x-on:keydown="removeOnKey($event)"')
->toContain('data-md-removable=""')
->not->toContain('wire:remove')
->and($html)
->toContain('data-chip-remove aria-label="Remove anna@example.com"')
->toContain('data-md-chip-remove aria-label="Remove anna@example.com"')
->toContain('wire:click="removeRecipient(3)"')
->toContain('x-on:click="removeChip($event)"')
->toContain('<input type="hidden" name="to[]" value="3" />')
->toContain('ps-2.75 pe-2')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip.css'))
// 18px icon, 15px out on every side: M3's 48dp minimum for the close icon's target.
->toContain('after:absolute after:-inset-3.75');
->toMatch('/\[data-md-chip-remove\] \{.*?&::after \{\s*content: \'\';\s*position: absolute;\s*inset: -15px;/s');
});
it('runs an Alpine remove expression, or takes the chip off the page with neither', function () {
@@ -201,7 +208,7 @@ it('runs an Alpine remove expression, or takes the chip off the page with neithe
it('leaves a client-rendered chip\'s remove button to be named in the browser', function () {
expect((string) $this->blade('<x-chip type="input" removable remove="tags.pop()"><span x-text="tag"></span></x-chip>'))
->toContain('data-chip-remove="Remove :label"')
->toContain('data-md-chip-remove="Remove :label"')
->not->toContain('aria-label=');
});
@@ -209,58 +216,72 @@ it('disables a removable input chip and its remove button', function () {
$html = (string) $this->blade('<x-chip type="input" label="php" removable disabled />');
expect($html)
->toContain('border-on-surface/12 text-on-surface/38')
->toMatch('/<button[^>]*data-chip-remove[^>]*disabled/s');
->toContain('data-md-disabled=""')
->toMatch('/<button[^>]*data-md-chip-remove[^>]*disabled/s');
});
it('groups chips in a wrapping row named by its label, with a hint', function () {
$html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these"><x-chip label="A" /></x-chip-set>');
$html = (string) $this->blade('<x-chip-set label="File types" hint="Show only these" class="mt-4"><x-chip label="A" /></x-chip-set>');
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
expect($html)
->toContain('role="group"')
->toContain('data-chip-set x-data="materialChipSet"')
->toContain('flex flex-wrap gap-2')
->toMatch('/<div\s+data-md-chip-set\s+role="group"/')
->toContain('class="mt-4"')
->toContain('<div data-md-chip-set-row x-data="materialChipSet"')
// M3's chip keyboard: the set is one tab stop and the arrows walk it.
->toContain('x-on:keydown="key($event)"')
->toContain('x-on:focusin="rove($event.target)"')
->toContain("id=\"{$labelledBy[1]}\" class=\"mb-2 type-label-lg text-on-surface-variant\">File types</p>")
->toContain("id=\"{$describedBy[1]}\" class=\"mt-1 type-body-sm text-on-surface-variant\">Show only these</p>");
->toContain("id=\"{$labelledBy[1]}\" data-md-chip-set-label>File types</p>")
->toContain("id=\"{$describedBy[1]}\" data-md-chip-set-hint>Show only these</p>")
->not->toContain('data-md-scroll')
->not->toContain('flex-wrap');
});
it('draws the set from its stylesheet: 8px between chips, wrapping unless it scrolls', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css');
expect($css)->toContain("@import './chip.css';")
->toMatch('/\[data-md-chip-set-row\] \{\s*display: flex;\s*flex-wrap: wrap;\s*gap: var\(--md-sys-measurement-space100\);/')
->toContain('[data-md-chip-set-scroller] > [data-md-chip-set-row] {')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/chip-set.css';");
});
it('scrolls a chip set on one line with fading edges', function () {
expect((string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>'))
->toContain('aria-label="Sort"')
->toContain('x-data="materialChipSet"')
->toMatch('/data-md-chip-set\s+data-md-scroll\s/')
->toContain('<div x-data="materialChipSet" data-md-chip-set-scroller>')
->toContain('x-ref="row"')
->toContain('wire:ignore.self')
->toContain('overflow-x-auto')
->toContain('data-scroll-start:[--chip-fade-start:1.5rem]')
->not->toContain('flex-wrap');
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
->toContain('overflow-x: auto;')
->toContain('--chip-fade-start: var(--md-sys-measurement-space300);');
});
it('puts a scroll button over each fading edge, for a pointer that cannot swipe', function () {
$html = (string) $this->blade('<x-chip-set aria-label="Sort" scroll><x-chip label="A" /></x-chip-set>');
expect($html)
->toContain('data-chip-scroll="start"')
->toContain('data-chip-scroll="end"')
->toContain('data-md-chip-scroll="start"')
->toContain('data-md-chip-scroll="end"')
->toContain('x-on:click="nudge(\'start\')"')
->toContain('x-on:click="nudge(\'end\')"')
// Only while the row can still scroll that way, and only where the pointer is fine.
->toContain('peer-data-scroll-start:pointer-fine:grid')
->toContain('peer-data-scroll-end:pointer-fine:grid')
// A pointer affordance, not a tab stop: the arrows already walk every chip.
->toContain('tabindex="-1"')
->toContain('aria-hidden="true"')
->toContain('data-md-mirror-rtl');
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/chip-set.css'))
// Only while the row can still scroll that way, and only where the pointer is fine.
->toMatch("/@media \(pointer: fine\) \{\s*\[data-md-chip-set-row\]\[data-md-scroll-start\] ~ \[data-md-chip-scroll='start'\],\s*\[data-md-chip-set-row\]\[data-md-scroll-end\] ~ \[data-md-chip-scroll='end'\] \{\s*display: grid;/")
// A chip the keyboard reaches clears the buttons as well as the fade.
->toContain('pointer-fine:scroll-px-10');
->toContain('scroll-padding-inline: var(--md-sys-measurement-space500);');
// A wrapping set has no edge to scroll towards.
expect((string) $this->blade('<x-chip-set aria-label="Sort"><x-chip label="A" /></x-chip-set>'))
->not->toContain('data-chip-scroll');
->not->toContain('data-md-chip-scroll');
});
it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {
+18 -2
View File
@@ -7,6 +7,8 @@ it('lays every option out as a filter chip', function () {
$html = (string) $this->blade('<x-choices label="Days" hint="Pick any" :value="[2]" :options="[[\'id\' => 1, \'name\' => \'Mon\'], [\'id\' => 2, \'name\' => \'Tue\'], [\'id\' => 3, \'name\' => \'Wed\', \'disabled\' => true]]" />');
expect($html)
->toMatch('/<div\\s+data-md-choices\\s/')
->not->toContain('data-md-searchable')
->toContain('role="group"')
->toContain('Days')
->toContain('Pick any')
@@ -42,6 +44,7 @@ it('draws a searchable combobox with a popover list', function () {
$html = (string) $this->blade('<x-choices id="zone" label="Time zone" searchable icon="public" value="Europe/Zurich" :options="[[\'id\' => \'Europe/Zurich\', \'name\' => \'Zurich\']]" />');
expect($html)
->toMatch('/<div\\s+data-md-choices\\s+data-md-searchable\\s/')
->toContain('role="combobox"')
->toContain('aria-controls="zone-list"')
->toContain('aria-expanded="false"')
@@ -51,11 +54,14 @@ it('draws a searchable combobox with a popover list', function () {
->toContain('popover="manual"')
->toMatch('/anchor-name: (--material-choices-[a-z0-9]{10})/')
->toContain('x-modelable="value"')
->toContain('field-arrow')
->toContain('data-md-field-arrow')
// WAI-ARIA's combobox keyboard, Home and End included.
->toContain('x-on:keydown.home="jump($event, false)"')
->toContain('x-on:keydown.end="jump($event, true)"')
->toContain('Nothing matches');
->toContain('<li x-show="filtered.length === 0" data-md-choices-empty>Nothing matches</li>')
->toContain('data-md-field-menu')
->toContain('data-md-field-option')
->not->toContain('class=');
preg_match('/anchor-name: (--material-choices-[a-z0-9]{10})/', $html, $anchor);
@@ -92,3 +98,13 @@ it('shows the errors for the property and its items', function () {
->toContain('Choose a time zone.')
->toContain('aria-invalid="true"');
});
it('brings the stylesheets of what it renders, and draws its empty row', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/choices.css');
expect($css)->toContain("@import './field.css';")
->toContain("@import './chip-set.css';")
->toContain("@import './chip.css';")
->toContain('[data-md-choices-empty] {')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/choices.css';");
});
+27 -7
View File
@@ -8,14 +8,25 @@ use Livewire\WithPagination;
it('marks a table for its styles and takes a size', function () {
expect((string) $this->blade('<x-table class="min-w-160"><tbody><tr><td>a</td></tr></tbody></x-table>'))
->toContain('<table data-table data-size="sm" class="min-w-160">')
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-size="xs"')
->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-size="sm"');
->toContain('<table data-md-table data-md-size="sm" class="min-w-160">')
->and((string) $this->blade('<x-table size="xs" />'))->toContain('data-md-size="xs"')
->and((string) $this->blade('<x-table size="huge" />'))->toContain('data-md-size="sm"');
});
it('tightens a table only when the caller asks for it', function () {
expect((string) $this->blade('<x-table />'))->not->toContain('data-dense')
->and((string) $this->blade('<x-table dense />'))->toContain('data-dense');
expect((string) $this->blade('<x-table />'))->not->toContain('data-md-dense')
->and((string) $this->blade('<x-table dense />'))->toContain('data-md-dense');
});
it('draws 52px rows, 36px dense ones, from its stylesheet in the components layer', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/table.css');
expect($css)->toContain('@layer material.components')
->toMatch('/\\[data-md-table\\] \\{\\s*--cell-x: 12px;\\s*--cell-y: var\\(--md-sys-measurement-space200\\);/')
->toMatch('/\\[data-md-table\\]\\[data-md-dense\\] \\{\\s*--cell-y: var\\(--md-sys-measurement-space100\\);/')
->toContain('[data-md-table] :where(tbody tr[aria-selected=\'true\'])')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/table.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('table.css');
});
it('sorts by its column, ascending first and then flipping', function () {
@@ -24,7 +35,7 @@ it('sorts by its column, ascending first and then flipping', function () {
->toContain('wire:click="$set(\'sortBy\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;asc&quot;})"')
->and((string) $this->blade('<x-sort-header column="size" model="order" :sort-by="[\'column\' => \'size\', \'direction\' => \'asc\']" class="text-end">Size</x-sort-header>'))
->toContain('aria-sort="ascending"')
->toContain('class="text-end"')
->toMatch('/<th data-md-sort-header\\s+data-md-active aria-sort="ascending"\\s+class="text-end">/')
->toContain('$set(\'order\', {&quot;column&quot;:&quot;size&quot;,&quot;direction&quot;:&quot;desc&quot;})')
->and((string) $this->blade('<x-sort-header column="size" :sort-by="[\'column\' => \'size\', \'direction\' => \'desc\']">Size</x-sort-header>'))
->toContain('aria-sort="descending"')
@@ -35,7 +46,9 @@ it('gives the sort button and every page control a 48px target', function () {
$pages = new LengthAwarePaginator(range(1, 10), 95, 10, 3, ['path' => '/shares']);
expect((string) $this->blade('<x-sort-header column="size" :sort-by="[]">Size</x-sort-header>'))
->toContain('touch-target')
->toMatch('/<button(?=[^>]*data-md-sort-header-button)(?=[^>]*class="md-focus-ring md-touch-target")[^>]*>/')
->toContain('--md-icon-size: 16px')
->not->toContain('data-md-active')
->and((string) $pages->links())
->toContain('state-layer focus-ring touch-target');
});
@@ -80,3 +93,10 @@ it('draws Livewire\'s paginators in M3, wired to its page actions', function ()
->toContain('wire:click="nextPage(\'page\')"')
->not->toContain('text-gray');
});
it('draws the sort header from its stylesheet, its arrow hidden until the column sorts or is hovered', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/sort-header.css'))
->toContain('@layer material.components')
->toMatch('/\\[data-md-sort-header\\]:not\\(\\[data-md-active\\]\\) \\[data-md-sort-header-arrow\\] \\{\\s*opacity: 0;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/sort-header.css';");
});
+6 -6
View File
@@ -21,7 +21,7 @@ it('draws a docked date field whose combobox controls a popover picker', functio
expect($html)
->toContain('data-datepicker')
->toContain('<label for="expires" class="field-label">Expires on</label>')
->toContain('<label for="expires" data-md-field-label>Expires on</label>')
->toContain('role="combobox"')
->toContain('aria-haspopup="dialog"')
->toContain('aria-controls="expires-picker"')
@@ -155,8 +155,8 @@ it('puts class on the root and every other attribute on the text field', functio
expect($html)
->toContain('class="w-60"')
->toContain('data-variant="filled"')
->toContain('data-size="sm"')
->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();
@@ -225,14 +225,14 @@ it('replaces the hint with the errors for the property and its start and end', f
->not->toContain('Pick a day')
->toContain('aria-invalid="true"')
->toContain('data-datepicker-error')
->and(substr_count($html, 'data-invalid=""'))->toBe(2);
->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-field-clear')
->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-field-clear');
->not->toContain('data-md-field-clear');
});
+135 -42
View File
@@ -4,31 +4,34 @@ it('draws an outlined field with a notched label unless told otherwise', functio
$html = (string) $this->blade('<x-input label="Share name" />');
expect($html)
->toContain('data-variant="outlined"')
->toContain('data-md-field')
->toContain('data-md-input')
->toContain('data-md-variant="outlined"')
->toContain('<legend><span>Share name</span></legend>')
->toMatch('/<label for="(field-[0-9a-f]{12})" class="field-label">Share name<\/label>/')
->toMatch('/<label for="(field-[0-9a-f]{12})" data-md-field-label>Share name<\/label>/')
->toContain('placeholder=" "')
->toContain('class="field-control"');
->toContain('data-md-field-control')
->not->toContain('class=');
});
it('draws a filled field on request, or by the application\'s default', function () {
expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-variant="filled"');
expect((string) $this->blade('<x-input label="Name" variant="filled" />'))->toContain('data-md-variant="filled"');
config(['livewire-material.fields.variant' => 'filled']);
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-variant="filled"')
->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-variant="outlined"')
->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-variant="filled"');
expect((string) $this->blade('<x-input label="Name" />'))->toContain('data-md-variant="filled"')
->and((string) $this->blade('<x-input label="Name" variant="outlined" />'))->toContain('data-md-variant="outlined"')
->and((string) $this->blade('<x-input label="Name" variant="glass" />'))->toContain('data-md-variant="filled"');
});
it('ties the label, the hint and the control together', function () {
$html = (string) $this->blade('<x-input id="share-name" label="Share name" hint="Recipients see this name" wire:model="name" />');
expect($html)
->toContain('<label for="share-name" class="field-label">')
->toContain('<label for="share-name" data-md-field-label>')
->toContain('id="share-name"')
->toContain('aria-describedby="share-name-support"')
->toContain('<p id="share-name-support" class="field-support">Recipients see this name</p>')
->toMatch('/<p id="share-name-support" data-md-field-support\s*>Recipients see this name<\/p>/')
->toContain('wire:model="name"')
->not->toContain('aria-invalid');
});
@@ -38,8 +41,9 @@ it('replaces the hint with the errors for its model', function () {
->blade('<x-input id="email" label="Email" hint="We never share it" wire:model.live="email" />');
expect($html)
->toContain('data-invalid')
->toContain('data-md-invalid')
->toContain('aria-invalid="true"')
->toContain('role="alert"')
->toContain('aria-describedby="email-support"')
->toContain('Enter a valid email address.')
->not->toContain('We never share it');
@@ -49,28 +53,26 @@ it('pairs the error colours with a trailing error icon', function () {
$errors = ['email' => ['Enter a valid email address.']];
expect((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" wire:model="email" />'))
->toContain('field-trailing field-error')
->toContain('aria-label="Error"')
->toMatch('/<svg(?=[^>]*data-md-field-trailing)(?=[^>]*data-md-field-error)(?=[^>]*aria-label="Error")(?=[^>]*role="img")[^>]*>/')
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" size="xs" wire:model="email" />'))
->not->toContain('field-error')
->not->toContain('data-md-field-error')
->and((string) $this->withViewErrors($errors)->blade('<x-input id="email" label="Email" icon-right="info" wire:model="email" />'))
->not->toContain('field-error')
->not->toContain('data-md-field-error')
->and((string) $this->blade('<x-input id="email" label="Email" />'))
->not->toContain('field-error');
->not->toContain('data-md-field-error');
});
it('puts icons, affixes and the size on the field, and every other attribute on the input', function () {
$html = (string) $this->blade('<x-input label="Price" icon="payments" icon-right="info" prefix="CHF" suffix="per month" size="sm" type="number" min="0" class="w-40" mono readonly />');
expect($html)
->toContain('class="field w-40"')
->toContain('data-size="sm"')
->toContain('data-mono')
->toContain('data-readonly')
->toContain('<span class="field-affix">CHF</span>')
->toContain('<span class="field-affix">per month</span>')
->toMatch('/<div\s+data-md-field\s+data-md-variant="outlined"\s+data-md-size="sm"\s+data-md-mono\s+class="w-40" data-md-readonly="" data-md-input="data-md-input"\s*>/')
->toContain('<span data-md-field-affix>CHF</span>')
->toContain('<span data-md-field-affix>per month</span>')
->toContain('type="number"')
->toContain('min="0"')
// An sm field's icons are 20px, drawn from the 20 cut.
->and(substr_count($html, '--md-icon-size: 20px'))->toBe(2)
->and(substr_count($html, '<svg'))->toBe(2);
});
@@ -78,9 +80,12 @@ it('offers to clear and to copy the value', function () {
$html = (string) $this->blade('<x-input label="Link" clearable copyable />');
expect($html)
->toContain('data-field-clear')
->toContain('data-md-field-clear')
->toContain('aria-label="Clear"')
->toContain('data-field-copy')
->toContain('data-md-field-copy')
->toContain("\$el.closest('[data-md-field]').querySelector('[data-md-field-control]')")
// A labelled field is md: its icons are 24px.
->toContain('--md-icon-size: 24px')
->toContain('navigator.clipboard.writeText')
->toContain('aria-label="Copy"');
});
@@ -92,7 +97,8 @@ it('lets a password be shown and hidden again', function () {
->toContain('type="password"')
->not->toContain('type="text"')
->toContain('x-bind:type="shown ? \'text\' : \'password\'"')
->toContain('data-field-reveal')
->toContain('data-md-password')
->toMatch('/<button(?=[^>]*data-md-field-button)(?=[^>]*data-md-field-reveal)[^>]*>/')
->toContain('aria-label="Show password"')
// The flipping label already says the state; aria-pressed would say it again, inverted.
->not->toContain('aria-pressed')
@@ -102,12 +108,13 @@ it('lets a password be shown and hidden again', function () {
it('grows a textarea from its rows up to its maximum, or leaves it to be resized', function () {
expect((string) $this->blade('<x-textarea label="Message" rows="2" max-rows="6" />'))
->toContain('rows="2"')
->toContain('data-autogrow')
->toContain('data-md-textarea')
->toContain('data-md-autogrow')
->toContain('--field-rows: 2;')
->toContain('--field-max-rows: 6;')
->and((string) $this->blade('<x-textarea label="Message" :autogrow="false" />'))
->toContain('rows="3"')
->not->toContain('data-autogrow');
->not->toContain('data-md-autogrow');
});
it('draws a native select whose label always floats', function () {
@@ -119,13 +126,15 @@ it('draws a native select whose label always floats', function () {
BLADE);
expect($html)
->toContain('data-floated')
->toContain('data-md-floated')
->toContain('data-md-select')
->toMatch('/<select(?=[^>]*data-md-field-control)[^>]*>/')
->toContain('<select')
->toContain('wire:model="hours"')
->toContain('<option value="">Choose…</option>')
->toContain('<option value="1" >1 hour</option>')
->toContain('<option value="720" disabled>30 days</option>')
->toContain('field-arrow');
->toContain('data-md-field-arrow');
});
it('takes a select\'s options from its slot as well', function () {
@@ -142,7 +151,8 @@ it('collects a file field\'s own errors and each file\'s', function () {
expect($html)
->toContain('type="file"')
->toContain('multiple')
->toContain('data-floated')
->toContain('data-md-file')
->toContain('data-md-floated')
->toContain('Choose at most 10 photos.')
->toContain('photo.jpg is larger than 2 GB.');
});
@@ -151,8 +161,10 @@ it('counts the characters of a field that has a maximum', function () {
$html = (string) $this->blade('<x-input id="bio" label="Bio" hint="Shown on your profile" maxlength="60" counter />');
expect($html)
->toContain('class="field-support-row"')
->toContain('class="field-counter"')
->toContain('data-md-field-support-row')
->toContain('data-md-field-counter')
->toContain('x-bind:data-md-over="used > max"')
->toContain('class="md-visually-hidden"')
->toContain('maxlength="60"')
->toContain('max: 60,')
->toContain('>0/60</span>')
@@ -160,24 +172,24 @@ it('counts the characters of a field that has a maximum', function () {
->toContain('Shown on your profile');
expect((string) $this->blade('<x-textarea id="note" label="Note" maxlength="140" counter />'))
->toContain('class="field-counter"')
->toContain('data-md-field-counter')
->toContain('>0/140</span>');
});
it('shows no counter without `counter` or without a maximum to count against', function () {
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('field-counter')
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('field-counter')
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('field-counter');
expect((string) $this->blade('<x-input label="Bio" maxlength="60" />'))->not->toContain('data-md-field-counter')
->and((string) $this->blade('<x-input label="Bio" counter />'))->not->toContain('data-md-field-counter')
->and((string) $this->blade('<x-textarea label="Note" counter />'))->not->toContain('data-md-field-counter');
});
it('bounds a field\'s width from medium unless it is told to fill its pane', function () {
expect(file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))
->toContain('@media (width >= 37.5rem)')
->toMatch('/\.field:not\(\[data-full\]\)\s*\{\s*max-width: 40rem;/');
->toContain('@media (width >= 600px)')
->toMatch('/\[data-md-field\]:not\(\[data-md-full\]\)\s*\{\s*max-width: 40rem;/');
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-full')
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-full')
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-full');
expect((string) $this->blade('<x-input label="Share name" full />'))->toContain('data-md-full')
->and((string) $this->blade('<x-textarea label="Message" full />'))->toContain('data-md-full')
->and((string) $this->blade('<x-input label="Share name" />'))->not->toContain('data-md-full');
});
it('lays out a form with its actions under an optional divider', function () {
@@ -189,10 +201,91 @@ it('lays out a form with its actions under an optional divider', function () {
BLADE);
expect($html)
->toContain('<form class="grid grid-cols-1 auto-rows-min gap-4" wire:submit="save">')
->toContain('<form data-md-form wire:submit="save">')
->toContain('role="separator"')
->toContain('justify-between')
->toContain('<div data-md-form-actions class="justify-between">')
->not->toContain('grid-cols-1')
->toContain('<button>Save</button>')
->and((string) $this->blade('<x-form><x-slot:actions><button>Save</button></x-slot:actions></x-form>'))
->not->toContain('role="separator"');
});
it('draws a form\'s column and its actions from its stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/form.css');
expect($css)->toContain('@layer material.components')
->toContain('grid-template-columns: minmax(0, 1fr);')
->toContain('gap: var(--md-sys-measurement-space200);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/form.css';");
});
it('keeps the field\'s chrome in its own stylesheet in the components layer', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css');
expect($css)->toContain('@layer material.components')
->toContain("@import './icon.css';")
->toContain('[data-md-field-box]')
->not->toMatch('/(?<![\\w-])\\.field\\b/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/field.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('field.css');
});
it('takes a custom control marked as the field\'s control, and a hint class', function () {
$html = (string) $this->blade(<<<'BLADE'
<x-field id="expiry" label="Expiry" hint="In the future" hint-class="md-ink-primary">
<input id="expiry" data-md-field-control value="2030-01-01" />
</x-field>
BLADE);
expect($html)
->toContain('<input id="expiry" data-md-field-control value="2030-01-01" />')
->toMatch('/<p id="expiry-support" data-md-field-support\s+class="md-ink-primary"\s*>In the future<\/p>/')
->toContain('<fieldset aria-hidden="true" data-md-field-outline>');
});
it('gives the one-line field a stylesheet that brings the field and its icons', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/input.css');
expect($css)->toStartWith('/*')
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/input.css';");
});
it('gives the password field a stylesheet that brings the field and its icons', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/password.css'))
->toContain("@import './field.css';")
->toContain("@import './icon.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/password.css';");
});
it('draws what a textarea changes in the field from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/textarea.css');
expect($css)->toContain("@import './field.css';")
->toContain('@layer material.components')
->toContain('textarea[data-md-field-control][data-md-autogrow]')
->toContain('field-sizing: content;')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('textarea[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/textarea.css';");
});
it('draws what a select changes in the field from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/select.css');
expect($css)->toContain("@import './field.css';")
->toContain('@supports (appearance: base-select)')
->toContain('select[data-md-field-control]:open')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain('select[')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/select.css';");
});
it('draws the file picker\'s button from its own stylesheet', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/file.css');
expect($css)->toContain("@import './field.css';")
->toContain("input[type='file'][data-md-field-control]::file-selector-button")
->toContain('background-color: var(--md-sys-color-secondary-container);')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/field.css'))->not->toContain("[type='file']")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/file.css';");
});
+33 -17
View File
@@ -4,7 +4,10 @@ it('draws a search bar whose combobox controls the view', function () {
$html = (string) $this->blade('<x-search id="find" placeholder="Search shares" wire:model.live.debounce.300ms="query" />');
expect($html)
->toContain('data-search')
->toContain('data-md-search')
->toContain('data-md-trigger="bar"')
->toContain('x-bind:data-md-open="open ? \'\' : null"')
->toContain('x-bind:data-md-full-screen="fullScreen ? \'\' : null"')
->toContain('role="search"')
->toContain('type="search"')
->toContain('placeholder="Search shares"')
@@ -15,14 +18,15 @@ it('draws a search bar whose combobox controls the view', function () {
->toContain('aria-expanded="false"')
->toContain('wire:model.live.debounce.300ms="query"')
->toContain('id="find-view"')
->toContain('data-search-clear')
->toContain('data-search-scrim')
->toContain('data-md-search-clear')
->toContain('data-md-search-scrim')
->toContain('aria-label="Back"')
->toContain('materialSearch(false,')
->not->toContain('data-search-results');
->not->toContain('data-md-search-results')
->not->toContain('class="relative"');
// The state belongs to the combobox, never to the bare textbox ARIA gives it to.
expect($html)->toMatch('/<span\s+data-search-field[^>]*aria-expanded/s');
expect($html)->toMatch('/<span\s+data-md-search-field[^>]*aria-expanded/s');
expect($html)->not->toMatch('/<input[^>]*aria-expanded/s');
});
@@ -30,7 +34,7 @@ it('announces how many results there are, politely', function () {
$html = (string) $this->blade('<x-search />');
expect($html)
->toContain('data-search-status')
->toContain('data-md-search-status class="md-visually-hidden"')
->toContain('aria-live="polite"')
->toContain('aria-atomic="true"')
->toContain('No results')
@@ -40,19 +44,19 @@ it('announces how many results there are, politely', function () {
it('shows the results as a list, or what to say when there are none', function () {
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
->toContain('data-search-results role="list"')
->toContain('data-md-search-results role="list"')
->toContain('<a href="/shares/1">holiday.zip</a>')
->and((string) $this->blade('<x-search><x-slot:empty>No shares match.</x-slot:empty></x-search>'))
->toContain('No shares match.')
->not->toContain('data-search-results role="list"');
->toContain('<p data-md-search-empty>No shares match.</p>')
->not->toContain('data-md-search-results role="list"');
});
it('offers M3\'s search-icon entry point', function () {
$html = (string) $this->blade('<x-search id="find" trigger="icon" label="Search shares" />');
expect($html)
->toContain('data-trigger="icon"')
->toContain('data-search-trigger')
->toContain('data-md-trigger="icon"')
->toContain('data-md-search-trigger')
->toContain('x-ref="trigger"')
->toContain('x-on:click="expand()"')
->toContain('aria-label="Search shares"')
@@ -61,11 +65,11 @@ it('offers M3\'s search-icon entry point', function () {
->toContain('materialSearch(false,')
->toContain("'icon'")
// The bar is still there: it becomes the expanded view's header.
->toContain('data-search-bar');
->toContain('data-md-search-bar');
expect((string) $this->blade('<x-search />'))
->toContain('data-trigger="bar"')
->not->toContain('data-search-trigger');
->toContain('data-md-trigger="bar"')
->not->toContain('data-md-search-trigger');
});
it('shows suggestions until the first keystroke, then the results', function () {
@@ -77,7 +81,7 @@ it('shows suggestions until the first keystroke, then the results', function ()
BLADE);
expect($html)
->toContain('data-search-suggestions role="list"')
->toContain('data-md-search-suggestions role="list"')
->toContain('x-show="query === \'\'"')
->toContain('Recent shares')
->toContain('x-show="query !== \'\'"')
@@ -87,7 +91,7 @@ it('shows suggestions until the first keystroke, then the results', function ()
// Without the slot the results stand alone and are never hidden.
expect((string) $this->blade('<x-search><a href="/shares/1">holiday.zip</a></x-search>'))
->not->toContain('data-search-suggestions')
->not->toContain('data-md-search-suggestions')
->not->toContain('x-show="query !== \'\'"');
});
@@ -97,7 +101,19 @@ it('names the input, trails the bar, and stays docked on request', function () {
expect($html)
->toContain('placeholder="Search"')
->toContain('aria-label="Search your shares"')
->toContain('data-search-trailing')
->toContain('data-md-search-trailing')
->toContain('<button>AR</button>')
->toContain('materialSearch(true,');
});
it('draws the bar and the view from its stylesheet, at M3\'s widths', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/search.css');
expect($css)->toContain('@layer material.components')
->toMatch('/\\[data-md-search\\] \\{\\s*--search-height: 56px;[^}]*--search-open-width: 720px;/')
->toContain('[data-md-search][data-md-full-screen] [data-md-search-view] {')
->toContain("[data-md-search][data-md-trigger='icon']:not([data-md-open]) [data-md-search-bar] {")
->not->toMatch('/\\d(?:\\.\\d+)?rem/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/search.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('search.css');
});
+73 -32
View File
@@ -1,40 +1,42 @@
<?php
it('draws a checkbox whose row is its label', function () {
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" />');
$html = (string) $this->blade('<x-checkbox id="notify" label="Notify me" hint="On every download" wire:model="notify" class="mt-2" style="order: 2" />');
expect($html)
->toContain('<div class="min-w-0 mt-2">')
->toContain('<label for="notify" data-selection')
->toContain('data-checkbox')
->toContain('<div data-md-checkbox class="mt-2" style="order: 2">')
->toMatch('/<label for="notify" data-md-selection-row\s*>/')
->toContain('<span data-md-checkbox-box >')
->toContain('type="checkbox"')
->toContain('wire:model="notify"')
->toContain('data-check')
->toContain('data-mixed')
->toContain('Notify me')
->toContain('On every download')
->not->toContain('data-indeterminate');
->toContain('data-md-checkbox-check')
->toContain('data-md-checkbox-mixed')
->toContain('<span data-md-selection-label>Notify me</span>')
->toContain('<span data-md-selection-hint>On every download</span>')
->not->toContain('data-md-indeterminate')
->not->toContain('min-w-0');
});
it('fills the box with an 18px tick from the 20 optical cut', function () {
expect((string) $this->blade('<x-checkbox label="Notify me" />'))
->toContain((string) $this->blade('<x-icon name="check" class="size-4.5" optical="20" data-check />'))
->toContain((string) $this->blade('<x-icon name="remove" class="size-4.5" optical="20" data-mixed />'));
->toContain((string) $this->blade('<x-icon name="check" size="18" data-md-checkbox-check />'))
->toContain((string) $this->blade('<x-icon name="remove" size="18" data-md-checkbox-mixed />'))
->toContain('--md-icon-size: 18px');
});
it('marks a checkbox that is partly ticked', function () {
expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-indeterminate');
expect((string) $this->blade('<x-checkbox label="Select all" indeterminate />'))->toContain('data-md-indeterminate');
});
it('gives a selection control without a label a 48px target', function () {
expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toContain('touch-target')
->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toContain('touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->toContain('touch-target');
expect((string) $this->blade('<x-checkbox aria-label="Select all" />'))->toMatch('/<span data-md-checkbox-box\s+class="md-touch-target"\s*>/')
->and((string) $this->blade('<x-toggle aria-label="Public link" />'))->toMatch('/<span data-md-switch\\s+class="md-touch-target"\\s*>/')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'\']]" />'))->toMatch('/<span data-md-radio-button\\s+class="md-touch-target"\\s*>/');
// A labelled control is pressed anywhere along its row, so it needs no extra target.
expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('touch-target')
->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('touch-target');
expect((string) $this->blade('<x-checkbox label="Notify me" />'))->not->toContain('md-touch-target')
->and((string) $this->blade('<x-toggle label="Public link" />'))->not->toContain('md-touch-target')
->and((string) $this->blade('<x-radio :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))->not->toContain('md-touch-target');
});
it('leaves a checkbox group to the caller and says how M3 wants it laid out', function () {
@@ -52,7 +54,7 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
->toContain('class="grid gap-4 expanded:grid-cols-2"')
->toContain('Downloads')
->toContain('Comments')
->and(substr_count($html, 'data-checkbox'))->toBe(2);
->and(substr_count($html, 'data-md-checkbox-box'))->toBe(2);
// The rule is guidance, not markup, so the component's header has to carry it.
expect(file_get_contents(__DIR__.'/../../../resources/views/components/checkbox.blade.php'))
@@ -61,7 +63,8 @@ it('leaves a checkbox group to the caller and says how M3 wants it laid out', fu
});
it('puts a checkbox at the end of its row on request', function () {
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toContain('flex-row-reverse justify-between');
expect((string) $this->blade('<x-checkbox label="Show" right />'))->toMatch('/<label for="check-[0-9a-f]{12}" data-md-selection-row\s+data-md-right\s*>/')
->and((string) $this->blade('<x-checkbox label="Show" />'))->not->toContain('data-md-right');
});
it('shows a checkbox\'s errors', function () {
@@ -69,7 +72,8 @@ it('shows a checkbox\'s errors', function () {
->blade('<x-checkbox id="terms" label="I accept the terms" wire:model="terms" />');
expect($html)
->toContain('data-invalid')
->toContain('data-md-invalid')
->toContain('<div id="terms-support" data-md-selection-support role="alert">')
->toContain('aria-invalid="true"')
->toContain('aria-describedby="terms-support"')
->toContain('Accept the terms to continue.');
@@ -85,8 +89,11 @@ it('draws radio buttons in a fieldset named by its question', function () {
BLADE);
expect($html)
->toContain('<fieldset')
->toContain('Who can open it</legend>')
->toMatch('/<fieldset\\s+data-md-radio\\s/')
->toContain('<legend data-md-radio-legend>Who can open it</legend>')
->toContain('<div data-md-radio-options>')
->toContain('<span data-md-selection-hint>Share it separately</span>')
->not->toContain('class=')
->and(substr_count($html, 'type="radio"'))->toBe(3)
->and(substr_count($html, 'name="audience"'))->toBe(3)
->and(substr_count($html, 'wire:model.live="audience"'))->toBe(3)
@@ -99,7 +106,7 @@ it('names unbound radios after their group and checks the given value', function
$html = (string) $this->blade('<x-radio name="theme" value="dark" inline :options="[[\'id\' => \'light\', \'name\' => \'Light\'], [\'id\' => \'dark\', \'name\' => \'Dark\']]" />');
expect($html)
->toContain('medium:flex')
->toMatch('/<fieldset\\s+data-md-radio\\s+data-md-inline\\s/')
->and(substr_count($html, 'name="theme"'))->toBe(2)
->and($html)->toMatch('/value="dark"\s+checked/')
->not->toMatch('/value="light"\s+checked/');
@@ -108,7 +115,8 @@ it('names unbound radios after their group and checks the given value', function
it('shows the errors of a radio group', function () {
expect((string) $this->withViewErrors(['audience' => ['Choose who can open it.']])
->blade('<x-radio wire:model="audience" :options="[[\'id\' => \'a\', \'name\' => \'A\']]" />'))
->toContain('data-invalid')
->toContain('data-md-invalid')
->toMatch('/<div id="radio-[0-9a-f]{12}-support" data-md-selection-support role="alert">/')
->toContain('Choose who can open it.');
});
@@ -116,17 +124,50 @@ it('draws a switch on a checkbox with the switch role', function () {
$html = (string) $this->blade('<x-toggle id="public" label="Public link" wire:model.live="public" right />');
expect($html)
->toContain('<label for="public" data-selection')
->toContain('data-switch')
->toMatch('/<div data-md-toggle\s*>/')
->toMatch('/<label for="public" data-md-selection-row\\s+data-md-right\\s*>/')
->toContain('data-md-switch')
->toContain('role="switch"')
->toContain('type="checkbox"')
->toContain('wire:model.live="public"')
->toContain('data-handle')
->toContain('flex-row-reverse')
->not->toContain('data-icons');
->toContain('data-md-switch-handle')
->not->toContain('class=')
->not->toContain('data-md-icons');
});
it('puts icons on a switch\'s handle', function () {
expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-icons="both"')->toContain('data-on')->toContain('data-off')
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-icons="selected"');
expect((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('data-md-icons="both"')->toContain('data-md-switch-on')->toContain('data-md-switch-off')
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons />'))->toContain('--md-icon-size: 16px')
->and((string) $this->blade('<x-toggle label="Wi-Fi" icons="selected" />'))->toContain('data-md-icons="selected"');
});
it('draws the checkbox from its stylesheet, on the row the selection controls share', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/checkbox.css');
expect($css)->toContain("@import './selection.css';")
->toContain("@import './icon.css';")
->toContain('@layer material.components')
->toMatch('/\\[data-md-checkbox-box\\] \\{[^}]*width: 18px;[^}]*height: 18px;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components/selection.css'))
->toContain('[data-md-selection-row]')
->toContain('@layer material.components')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/checkbox.css';")
->and((string) file_get_contents(__DIR__.'/../../../resources/css/tailwind.css'))->not->toContain('selection.css');
});
it('lays the radio group out from its stylesheet, inline only from 600px', function () {
$css = (string) file_get_contents(__DIR__.'/../../../resources/css/components/radio.css');
expect($css)->toContain("@import './selection.css';")
->toMatch('/@media \\(width >= 600px\\) \\{\\s*\\[data-md-radio\\]\\[data-md-inline\\] \\[data-md-radio-options\\] \\{\\s*display: flex;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/radio.css';")
->and((string) $this->blade('<x-radio name="size" class="mt-4" style="order: 1" :options="[[\'id\' => \'s\', \'name\' => \'S\']]" />'))
->toMatch('/data-md-radio\\s+class="mt-4" style="order: 1"/');
});
it('draws the switch from its stylesheet', function () {
expect((string) file_get_contents(__DIR__.'/../../../resources/css/components/toggle.css'))
->toContain("@import './selection.css';")
->toMatch('/\\[data-md-switch\\] \\{[^}]*width: 52px;[^}]*height: 32px;/')
->and((string) file_get_contents(__DIR__.'/../../../resources/css/components.css'))->toContain("@import './components/toggle.css';");
});
+100 -66
View File
@@ -19,8 +19,10 @@ it('is a labelled native range input under a drawing only the script touches', f
->toContain('value="40"')
->toContain('x-data="materialSlider"')
->toContain('x-on:pointerdown="press($event)"')
->toContain('data-slider-drawing wire:ignore aria-hidden="true"')
->toContain('noscript:appearance-auto')
->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"');
});
@@ -28,19 +30,24 @@ it('draws the first frame on the server: the track split around the handle, and
$html = (string) $this->blade('<x-slider value="40" />');
expect($html)
->toMatch('/data-segment="active"\s+class="[^"]*bg-primary[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(40% - 8px\); border-radius: 8px 2px 2px 8px"/')
->toMatch('/data-segment="end"\s+class="[^"]*bg-secondary-container[^"]*"\s+style="left: calc\(40% \+ 8px\); width: calc\(60% - 8px\); border-radius: 2px 8px 8px 2px"/')
->toMatch('/data-segment="start"\s+hidden/')
->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-handle="start" class="group/thumb absolute inset-y-0 w-0" style="left: calc(40% + 0px)">')
->toMatch('/data-value-label\s+class="[^"]*opacity-0[^"]*"\s*>40<\/span>/');
->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 () {
expect((string) $this->blade('<x-slider value="40" />'))
->toContain('flex h-11 min-w-12 items-center')
->toMatch('/data-value-label[^>]*class="[^"]*origin-bottom/')
->toMatch('/data-stop="end"[^>]*class="[^"]*bg-on-secondary-container/');
$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/components.css'))->toContain("@import './components/slider.css';");
});
it('snaps and clamps the value to the step grid', function (string $template, string $value) {
@@ -77,7 +84,8 @@ it('gives a range two inputs bound to the ends of an array, in a named group', f
->toContain('aria-label="Range start"')
->toContain('aria-label="Range end"')
->toContain('id="price-end"')
->toContain('data-handle="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)
@@ -123,7 +131,7 @@ it('replaces the hint with the validation message and marks the input invalid',
expect((string) $this->blade('<x-slider name="volume" hint="Quietly" id="volume" />'))
->toContain('Too loud.')
->toContain('text-error')
->toContain('<div id="volume-hint" data-md-slider-errors>')
->toContain('aria-invalid="true"')
->toContain('aria-describedby="volume-hint"')
->not->toContain('Quietly');
@@ -133,105 +141,131 @@ it('replaces the hint with the validation message and marks the input invalid',
->not->toContain('Per night');
expect((string) $this->blade('<x-slider name="balance" hint="Centre is even" />'))
->toContain('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 $body) {
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-size=\"{$size}\"")
->toContain("-translate-y-1/2 {$track}\"")
->toContain($handle)
->toMatch("/group\\/slider [^\"]* {$body} /");
->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', 'h-4', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
'sm' => ['sm', 'h-6', 'h-11 group-data-focused/thumb:h-9.5', 'h-12'],
'md' => ['md', 'h-10', 'h-13 group-data-focused/thumb:h-11.5', 'h-13'],
'lg' => ['lg', 'h-14', 'h-17 group-data-focused/thumb:h-15.5', 'h-17'],
'xl' => ['xl', 'h-24', 'h-27 group-data-focused/thumb:h-25.5', 'h-27'],
'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" />'))
->toContain('data-track-icon')
->toMatch('/data-track-icon\s+data-active/')
->toContain('text-on-secondary-container data-active:text-on-primary');
->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-track-icon\s+class/')
->toContain('size-8');
->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-track-icon');
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-centered')
->toMatch('/data-segment="start"\s+class="[^"]*"\s+style="left: calc\(0% \+ 0px\); width: calc\(50% - 6px\)/')
->toMatch('/data-segment="active"\s+class="[^"]*"\s+style="left: calc\(50% \+ 0px\); width: calc\(25% - 8px\)/')
->toMatch('/data-segment="end"\s+class="[^"]*"\s+style="left: calc\(75% \+ 8px\)/');
->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-tick="'))->toBe(11)
->and($html)->toContain('data-tick="0.5"')
->and($html)->toContain('bg-on-secondary-container data-active:bg-on-primary');
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-tick=')
->and((string) $this->blade('<x-slider ticks step="any" />'))->not->toContain('data-tick=')
->and((string) $this->blade('<x-slider :max="10" />'))->not->toContain('data-tick=');
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('opacity-0 scale-75 group-data-pressed/thumb:opacity-100');
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-value-label')
->toContain('mt-12')
->not->toContain('opacity-0 scale-75');
->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-value-label');
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="h-64" />');
expect($html)
->toContain('data-orientation="vertical"')
->toContain('data-md-orientation="vertical"')
->toContain('aria-orientation="vertical"')
// The slider is as wide as a horizontal one is tall, and as long as its wrapper.
->toContain('w-12')
->toContain('touch-pan-x h-48 min-h-0 flex-auto [container-type:size]')
->toContain('h-[100cqw] w-[calc(100cqh-0.25rem)] -translate-1/2 -rotate-90')
// The value label is turned back, so it lands beside the handle and reads across.
->toMatch('/data-value-label[^>]*class="[^"]*rotate-90/')
->not->toContain('origin-bottom')
->not->toContain('rtl:-scale-x-100');
->toContain('class="h-64"');
// The sizes are the horizontal ones, across instead of along.
expect((string) $this->blade('<x-slider orientation="vertical" size="xl" />'))->toContain('w-27')
->and((string) $this->blade('<x-slider orientation="vertical" value-label="always" />'))->toContain('ms-12');
$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-orientation')
->not->toContain('aria-orientation')
->toContain('h-12');
->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("absolute inset-y-0 {$active} group-has-disabled/slider:bg-on-surface/38")
->toContain("absolute inset-y-0 {$inactive} group-has-disabled/slider:bg-on-surface/12")
->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', 'bg-primary', 'bg-secondary-container'],
'tertiary' => ['tertiary', 'bg-tertiary', 'bg-tertiary-container'],
'error' => ['error', 'bg-error', 'bg-error-container'],
'unknown' => ['pink', 'bg-primary', 'bg-secondary-container'],
'primary' => ['primary', 'primary', 'secondary-container'],
'tertiary' => ['tertiary', 'tertiary', 'tertiary-container'],
'error' => ['error', 'error', 'error-container'],
'unknown' => ['pink', 'primary', 'secondary-container'],
]);
+7 -7
View File
@@ -19,14 +19,14 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
$html = (string) $this->blade('<x-timepicker id="starts" label="Starts at" hint="In your time zone" value="14:30" format="24" />');
expect($html)
->toContain('<label for="starts" class="field-label">Starts at</label>')
->toContain('<label for="starts" data-md-field-label>Starts at</label>')
->toMatch('/<input[^>]*id="starts"[^>]*readonly/s')
->toContain('value="14:30"')
->toContain('aria-haspopup="dialog"')
->toContain('aria-controls="starts-dialog"')
->toContain('aria-expanded="false"')
->toContain('aria-describedby="starts-support"')
->toContain('<p id="starts-support" class="field-support">In your time zone</p>')
->toMatch('/<p id="starts-support" data-md-field-support\s*>In your time zone<\/p>/')
->toContain('x-on:keydown.enter.prevent="show()"')
->toContain('data-timepicker-open')
->toContain('aria-label="Choose time"')
@@ -38,7 +38,7 @@ it('draws a read-only field that opens the picker in a labelled dialog', functio
->toContain('x-modelable="value"')
->toContain("materialTimepicker( '14:30' ,")
->not->toContain('aria-invalid="true"')
->not->toContain('data-invalid');
->not->toContain('data-md-invalid');
});
it('draws the dial as a slider, with twelve numbers per ring and M3\'s 24-hour inner ring', function () {
@@ -153,7 +153,7 @@ it('shows the errors for its property in place of the hint', function () {
});
expect(Livewire::test('timepicker-errors')->html())
->toContain('data-invalid')
->toContain('data-md-invalid')
->toContain('aria-invalid="true"')
->toContain('aria-describedby="starts-support"')
->toContain('Choose a time during opening hours.')
@@ -165,13 +165,13 @@ it('puts the field props on the field, its attributes on the input, and posts a
expect($html)
->toMatch('/^<div\s+class="w-60"/')
->toContain('data-variant="filled"')
->toContain('data-size="sm"')
->toContain('data-md-variant="filled"')
->toContain('data-md-size="sm"')
->toContain('data-timepicker-field')
->toContain('required')
->toContain('placeholder="hh:mm"')
->toContain('<input type="hidden" name="alarm" value="06:30" x-bind:value="value ?? \'\'" />')
->toContain('data-field-clear')
->toContain('data-md-field-clear')
->toMatch('/<button[^>]*data-timepicker-open[^>]*disabled/s')
->and(substr_count($html, 'class="field w-60"'))->toBe(0);
});
+1 -1
View File
@@ -46,7 +46,7 @@ it('renders components that use others under a prefix', function () {
expect(Blade::render('<x-m::drawer title="Share" with-close-button separator><x-m::input label="Name" clearable /></x-m::drawer>'))
->toContain('aria-label="Close"')
->toContain('role="separator"')
->toContain('data-field-clear');
->toContain('data-md-field-clear');
});
it('is not shadowed by an application component of the same name', function () {
+5 -1
View File
@@ -555,9 +555,13 @@ it('draws the interaction classes as the utilities the Tailwind components still
->and(stylesheetBlock($interaction, '.md-touch-target'))->toContain('min-width: var(--md-sys-measurement-space600);');
});
it('declares the material layers in the Workbench before Tailwind\'s', function () {
it('declares the material layers in the Workbench between Tailwind\'s preflight and its utilities', function () {
$app = stylesheetWithoutComments(File::get(__DIR__.'/../../workbench/resources/css/app.css'));
// The first statement orders the layers: above preflight, whose `* { padding: 0 }` would
// otherwise beat a rewritten component's padding, and below the utilities.
expect(trim($app))->toStartWith('@layer theme, base, material, components, utilities;');
$foundation = strpos($app, "@import '../../../resources/css/foundation.css';");
$tailwind = strpos($app, "@import 'tailwindcss'");
$components = strpos($app, "@import '../../../resources/css/tailwind.css';");