An over-engineering audit of the whole tree, applied in five reviewed batches. Behaviour stays the same except where UPGRADE.md says otherwise. PHP: the showcase and error-page stylesheets are prebuilt into resources/dist by bin/stylesheets.mjs, through Vite's own postcss-import (first occurrence kept, the order an application's build gives), instead of Stylesheets::bundle() inlining imports on every request; only the import walk DesignGuard needs stays. SchemeStylesheet::withProfiles() replaces three copies of the scheme-plus-profiles loop, material:scheme leaves spec and contrast checks to the node script that already made them, and the error page's scheme cache, the hashed view namespace, the translations path with no lang/ folder and DesignGuard's 1.x-name hints are gone. JS: the androidx shape port progress.js and both bin scripts each carried lives once in resources/js/shapes.js (the generated SVGs are unchanged); util.js holds ringIndex(), ms(), reopenGuard() and remember(), which were written out several times; listeners are released through AbortController; tooltip.js's hoverPopover() serves the rich tooltip too. CSS: every rule for an element inside the navigation rail queries `--md-navigation-rail-value` instead of repeating the seven collapsed conditions under five media branches; badge, alert, progress, slider and button read one non-inheriting colour-role table (components/color.css); the dialog chrome, the submenu's popover chrome, the chip's state layer and touch target, and the visually-hidden inputs use the shared rules they copied; foundation/tokens.css is folded into foundation.css. Views: Support\Field and Support\Link replace the error-key, bound-value and link-attribute blocks copied into the fields and link components; the timepicker period group, the menu filter and the showcase head are partials; the datepicker's steppers and entry fields are loops; component docblocks no longer restate SKILL.md. Tests and tooling: one dataset-driven ComponentStylesheetsTest replaces four per-group files, DesignGuardTest and the layout-component tests use datasets, browser tests share one ready() helper, CSS parsing lives in ComponentStylesheet alone. docs/audits and the finding IDs citing it are removed, as are pestphp/pest-plugin-laravel, the unused composer scripts and check:font; the lint job runs in the feature job, which now installs node packages so the prebuilt-stylesheet staleness test runs in CI. Feature suite 1177 passed, Chrome browser suite 299 passed. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
325 lines
17 KiB
PHP
325 lines
17 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="app-chip-gap" />', 'button'))
|
|
->toBe('<button class="md-state-layer md-touch-target app-chip-gap" data-md-chip="assist" data-md-icon-start="" type="button">')
|
|
->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>')
|
|
->toContain('class="md-state-layer md-touch-target"');
|
|
});
|
|
|
|
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;');
|
|
});
|
|
|
|
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="app-filter-gap" wire:key="photos" />');
|
|
|
|
expect(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" wire:key="photos" />', 'label'))
|
|
->toBe('<label class="md-state-layer md-touch-target app-filter-gap" data-md-chip="filter" wire:key="photos">')
|
|
->and(chipTag('<x-chip type="filter" label="Photos" value="photos" x-model="kinds" class="app-filter-gap" />', 'input'))
|
|
->toContain('type="checkbox"')
|
|
->toContain('value="photos"')
|
|
->toContain('x-model="kinds"')
|
|
->toContain('class="md-visually-hidden"')
|
|
->not->toContain('app-filter-gap')
|
|
->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')
|
|
// An input chip draws its state layer on its action, never a second one on the container.
|
|
->not->toContain('md-state-layer')
|
|
->and(chipTag('<x-chip type="input" label="Anna" icon="person" wire:click="open(1)" />', 'button'))
|
|
->not->toContain('md-state-layer')
|
|
->not->toContain('md-touch-target');
|
|
});
|
|
|
|
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="app-chip-set-gap"><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="app-chip-set-gap"')
|
|
->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] {');
|
|
});
|
|
|
|
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');
|
|
});
|