Plan step 37: all.css replaces the two interim import lists with one entry for an application that wants everything — the foundation, every layout stylesheet and every component stylesheet, grouped under the same block comments components.css used, plus a Layout block. It also directly imports the three files nothing imported by name before (layout/spacing.css, layout/visibility.css, components/selection.css), so every file under components/ and layout/ is now one @import away. Every test that read a block of components.css or layout.css now reads the matching block of all.css through one shared helper (allCssBlock(), in tests/Pest.php so it loads for any test run) instead of repeating the same substr() search in each file. StylesheetsTest.php's shape, Tailwind-free and breakpoint checks, previously run twice (once for the foundation, once for the layout tree), now run once over the whole tree all.css reaches, since every component and layout stylesheet is plain CSS after step 36; a new test asserts all.css imports everything under components/ and layout/ exactly once. The Workbench imports all.css in place of layout.css and components.css. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
322 lines
16 KiB
PHP
322 lines
16 KiB
PHP
<?php
|
|
|
|
use Livewire\Component;
|
|
use Livewire\Livewire;
|
|
|
|
/**
|
|
* The opening tag of the first element in the rendered Blade that matches `$tag`.
|
|
*/
|
|
function chipTag(string $blade, string $tag = '[a-z]+'): string
|
|
{
|
|
preg_match('/<(?:'.$tag.')\b[^>]*>/s', (string) test()->blade($blade), $matches);
|
|
|
|
return $matches[0] ?? '';
|
|
}
|
|
|
|
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" 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)
|
|
->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/all.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'))
|
|
->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-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('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('data-md-disabled=""')
|
|
->toMatch('/\sdisabled="disabled"/')
|
|
->and(chipTag('<x-chip label="Share" elevated disabled />', 'button'))
|
|
->toContain('data-md-elevated=""')
|
|
->toContain('data-md-disabled=""');
|
|
});
|
|
|
|
it('links with wire:navigate, and a disabled link leaves the tab order', function () {
|
|
expect(chipTag('<x-chip label="Directions" link="/directions" />', 'a'))
|
|
->toContain('href="/directions"')
|
|
->toContain('wire:navigate')
|
|
->not->toContain('type=')
|
|
->and(chipTag('<x-chip label="Guide" link="https://m3.material.io" external />', 'a'))
|
|
->toContain('target="_blank"')
|
|
->toContain('rel="noopener"')
|
|
->not->toContain('wire:navigate')
|
|
->and(chipTag('<x-chip label="Directions" link="/directions" disabled />', 'a'))
|
|
->toContain('aria-disabled="true"')
|
|
->toContain('tabindex="-1"')
|
|
->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-md-chip="suggestion"')
|
|
->and(chipTag('<x-chip type="suggestion" label="Sounds good" elevated />', 'button'))
|
|
->toContain('data-md-elevated=""');
|
|
});
|
|
|
|
it('attaches a plain tooltip', function () {
|
|
$html = (string) $this->blade('<x-chip label="Share" tooltip="Share this file" />');
|
|
|
|
expect($html)
|
|
->toContain('popover="manual"')
|
|
->toContain('Share this file')
|
|
->toMatch('/<button[^>]*style="anchor-name: --material-chip-[a-z0-9]{10}"/');
|
|
});
|
|
|
|
it('makes a filter chip without a model a toggle button the caller owns', function () {
|
|
$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-md-chip="filter"')
|
|
->toContain('aria-pressed="true"')
|
|
->toContain('wire:click="toggleStarred"')
|
|
->and(chipTag('<x-chip type="filter" label="Starred" />', 'button'))
|
|
->toContain('aria-pressed="false"')
|
|
->and($html)
|
|
->not->toContain('type="checkbox"')
|
|
->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'))
|
|
->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('class="md-visually-hidden"')
|
|
->not->toContain('me-4')
|
|
->not->toContain('checked')
|
|
->and($html)
|
|
->toContain('data-md-chip-check');
|
|
});
|
|
|
|
it('submits a named filter chip, checked when selected, and greys a disabled one', function () {
|
|
expect(chipTag('<x-chip type="filter" label="Starred" name="starred" :selected="true" />', 'input'))
|
|
->toContain('name="starred"')
|
|
->toContain('checked')
|
|
->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('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)
|
|
->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-md-chip="input"')
|
|
->toContain('data-md-avatar=""')
|
|
->not->toContain('x-data')
|
|
->and((string) $this->blade('<x-chip type="input" label="Anna" avatar="AM" />'))
|
|
->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="" 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 () {
|
|
expect(chipTag('<x-chip type="input" label="Anna" icon="person" :selected="true" wire:click="open(1)" />', 'button'))
|
|
->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('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 () {
|
|
$html = (string) $this->blade('<x-chip type="input" label="anna@example.com" removable wire:remove="removeRecipient(3)" name="to[]" value="3" />');
|
|
|
|
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-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" />')
|
|
->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.
|
|
->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 () {
|
|
expect((string) $this->blade('<x-chip type="input" label="php" removable remove="tags.splice(0, 1)" />'))
|
|
->toContain('x-on:click="removeChip($event); tags.splice(0, 1)"')
|
|
->not->toContain('wire:click')
|
|
->and((string) $this->blade('<x-chip type="input" label="php" removable />'))
|
|
->toContain('x-on:click="removeChip($event); $root.remove()"');
|
|
});
|
|
|
|
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-md-chip-remove="Remove :label"')
|
|
->not->toContain('aria-label=');
|
|
});
|
|
|
|
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('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" class="mt-4"><x-chip label="A" /></x-chip-set>');
|
|
|
|
preg_match('/aria-labelledby="([^"]+)"/', $html, $labelledBy);
|
|
preg_match('/aria-describedby="([^"]+)"/', $html, $describedBy);
|
|
|
|
expect($html)
|
|
->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]}\" 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/all.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"')
|
|
->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')
|
|
->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-md-chip-scroll="start"')
|
|
->toContain('data-md-chip-scroll="end"')
|
|
->toContain('x-on:click="nudge(\'start\')"')
|
|
->toContain('x-on:click="nudge(\'end\')"')
|
|
// 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('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-md-chip-scroll');
|
|
});
|
|
|
|
it('binds filter chips to a Livewire array and shows the set\'s validation message in place of its hint', function () {
|
|
$component = new class extends Component
|
|
{
|
|
/** @var list<string> */
|
|
public array $kinds = ['photos'];
|
|
|
|
public function save(): void
|
|
{
|
|
$this->validate(['kinds' => 'max:1'], ['kinds.max' => 'Pick one kind.']);
|
|
}
|
|
|
|
public function render(): string
|
|
{
|
|
return <<<'BLADE'
|
|
<div>
|
|
<x-chip-set label="Kinds" hint="Show only these" error-field="kinds">
|
|
<x-chip type="filter" label="Photos" value="photos" wire:model.live="kinds" />
|
|
<x-chip type="filter" label="Documents" value="documents" wire:model.live="kinds" />
|
|
</x-chip-set>
|
|
</div>
|
|
BLADE;
|
|
}
|
|
};
|
|
|
|
Livewire::test($component)
|
|
->assertSeeHtml('wire:model.live="kinds"')
|
|
->assertSeeHtml('value="photos" checked')
|
|
->assertDontSeeHtml('value="documents" checked')
|
|
->assertSee('Show only these')
|
|
->set('kinds', ['photos', 'documents'])
|
|
->assertSeeHtml('value="documents" checked')
|
|
->call('save')
|
|
->assertSee('Pick one kind.')
|
|
->assertDontSee('Show only these');
|
|
});
|