Merge branch 'worktree-agent-a41c756369d016a29'
This commit is contained in:
@@ -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 () {
|
||||
|
||||
@@ -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';");
|
||||
});
|
||||
|
||||
@@ -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\', {"column":"size","direction":"asc"})"')
|
||||
->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\', {"column":"size","direction":"desc"})')
|
||||
->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';");
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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';");
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
|
||||
@@ -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';");
|
||||
});
|
||||
|
||||
@@ -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'],
|
||||
]);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user