Draw the split button without Tailwind
Plan step 36. <x-split-button> renders data-md-split-button and data-md-size on the pair and data-md-split on its halves. split-button.css draws SplitButton*Tokens' shape — full outer corners, inner corners that grow under the finger, the trailing half round while its menu is open — the narrower leading padding and 48px trailing half at xs and sm, and the chevron's own size, nudge and turn on the standard scheme. groups.css is empty and leaves tailwind.css. The actions browser test follows data-md-split and data-md-button-group, and gains the owed tests for a connected segment's 48px edge and a button group's single-required and multi selection. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Qwx5USif3wFFmxtHg5U1g9
This commit is contained in:
co-authored by
Claude Opus 5
parent
8dd0a7ef9d
commit
2a4dcead3d
@@ -222,10 +222,10 @@ it('moves a connected group\'s choice with the arrow keys', function () {
|
||||
});
|
||||
|
||||
it('rounds a split button\'s trailing half while its menu is open', function () {
|
||||
$trailing = "document.querySelector('[data-split=\"trailing\"]')";
|
||||
$trailing = "document.querySelector('[data-md-split=\"trailing\"]')";
|
||||
|
||||
showcase()
|
||||
->click('[data-split="trailing"] >> nth=0')
|
||||
->click('[data-md-split="trailing"] >> nth=0')
|
||||
->assertScript("{$trailing}.getAttribute('aria-expanded') === 'true'")
|
||||
->assertScript("getComputedStyle({$trailing}).borderTopLeftRadius === ({$trailing}.offsetHeight / 2) + 'px'");
|
||||
});
|
||||
@@ -235,7 +235,7 @@ it('keeps a connected segment\'s small inner corners, which a 9999px outer corne
|
||||
// factor: a full corner written as 9999px drew the 8px inner corners square. Every radius
|
||||
// in a connected group or a split button stays within half its height, so none is scaled.
|
||||
showcase()
|
||||
->assertScript("[...document.querySelectorAll('[data-button-group=\"connected\"] > *, [data-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })")
|
||||
->assertScript("[...document.querySelectorAll('[data-md-button-group=\"connected\"] > *, [data-md-split]')].every((el) => { const cs = getComputedStyle(el); const half = el.offsetHeight / 2 + 0.5; return el.offsetHeight === 0 || ['borderTopLeftRadius', 'borderTopRightRadius', 'borderBottomLeftRadius', 'borderBottomRightRadius'].every((corner) => parseFloat(cs[corner]) <= half); })")
|
||||
->assertScript("(el => getComputedStyle(el).borderTopRightRadius === '8px')(document.querySelector('#buttons input[name=\"showcase-theme\"][value=\"light\"]').parentElement)");
|
||||
});
|
||||
|
||||
@@ -464,3 +464,52 @@ it('closes a FAB menu when an item\'s action runs, and opens and closes it clean
|
||||
->assertScript('! '.FAB_MENU.".matches(':popover-open')")
|
||||
->assertScript(focused("getAttribute('aria-label') === 'New'"));
|
||||
});
|
||||
|
||||
it('takes a press on a small connected segment at its 48px edge, past what it draws', function () {
|
||||
// M3: "XS and S connected button groups have a 48dp target area and a 48dp minimum width".
|
||||
showcase()->assertScript(<<<'JS'
|
||||
(() => {
|
||||
const segment = document.querySelector('#buttons input[name="showcase-theme"][value="light"]').parentElement
|
||||
segment.scrollIntoView({ block: 'center' })
|
||||
const box = segment.getBoundingClientRect()
|
||||
const x = box.left + box.width / 2
|
||||
return box.height === 40 && box.width >= 48
|
||||
&& document.elementFromPoint(x, box.top - 3) === segment
|
||||
&& document.elementFromPoint(x, box.bottom + 3) === segment
|
||||
})()
|
||||
JS);
|
||||
});
|
||||
|
||||
it('keeps one choice in a single, required selection group', function () {
|
||||
$group = '#buttons [data-md-selection="single"]';
|
||||
$view = "document.querySelector('{$group}').parentElement.querySelector('code').textContent";
|
||||
|
||||
$page = showcase();
|
||||
|
||||
$page->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'true')
|
||||
->click("{$group} button:has-text(\"Day\")")
|
||||
->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true')
|
||||
->assertAttribute("{$group} button:has-text(\"Week\")", 'aria-pressed', 'false')
|
||||
->assertScript("{$view} === 'day'");
|
||||
|
||||
// Required: pressing the chosen button again leaves it chosen.
|
||||
$page->click("{$group} button:has-text(\"Day\")")
|
||||
->assertAttribute("{$group} button:has-text(\"Day\")", 'aria-pressed', 'true')
|
||||
->assertScript("{$view} === 'day'");
|
||||
});
|
||||
|
||||
it('toggles each choice of a multi selection group, down to none', function () {
|
||||
$group = '#buttons [data-md-selection="multi"]';
|
||||
$marks = "document.querySelector('{$group}').parentElement.querySelectorAll('code')[1].textContent";
|
||||
|
||||
showcase()
|
||||
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'true')
|
||||
->click("{$group} [aria-label=\"Italic\"]")
|
||||
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'true')
|
||||
->assertScript("{$marks} === 'bold, italic'")
|
||||
->click("{$group} [aria-label=\"Bold\"]")
|
||||
->click("{$group} [aria-label=\"Italic\"]")
|
||||
->assertAttribute("{$group} [aria-label=\"Bold\"]", 'aria-pressed', 'false')
|
||||
->assertAttribute("{$group} [aria-label=\"Italic\"]", 'aria-pressed', 'false')
|
||||
->assertScript("{$marks} === 'none'");
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ dataset('action components', [
|
||||
'button',
|
||||
'group',
|
||||
'button-group',
|
||||
'split-button',
|
||||
]);
|
||||
|
||||
it('draws the component from a stylesheet shaped like every package stylesheet', function (string $name) {
|
||||
@@ -37,8 +38,9 @@ it('imports the stylesheet of every component its view renders', function (strin
|
||||
preg_match_all('/<x-livewire-material::([a-z-]+)/', File::get(__DIR__."/../../../resources/views/components/{$name}.blade.php"), $tags);
|
||||
|
||||
$rendered = collect($tags[1])->unique()
|
||||
// A component still written for Tailwind has no stylesheet of its own to import yet.
|
||||
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)))
|
||||
// A component still written for Tailwind has no stylesheet of its own to import yet; its
|
||||
// old stylesheet, if it has one, lives in tailwind.css without the layer statement.
|
||||
->filter(fn (string $tag): bool => is_file(ComponentStylesheet::path($tag)) && str_contains(File::get(ComponentStylesheet::path($tag)), '@layer material.components'))
|
||||
->map(fn (string $tag): string => "./{$tag}.css")
|
||||
->values()
|
||||
->all();
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
<?php
|
||||
|
||||
use NoNameWeb\LivewireMaterial\Tests\Support\ComponentStylesheet;
|
||||
|
||||
it('puts the action on the leading button and the menu on the trailing one', function () {
|
||||
$html = (string) $this->blade(<<<'BLADE'
|
||||
<x-split-button label="Download all" icon="download" wire:click="downloadZip" menu-label="Download options" class="mt-4">
|
||||
@@ -8,27 +10,34 @@ it('puts the action on the leading button and the menu on the trailing one', fun
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('data-button-group="split"')
|
||||
->toContain('mt-4')
|
||||
->toContain('data-split="leading"')
|
||||
->toContain('wire:click="downloadZip"')
|
||||
->toContain('data-split="trailing"')
|
||||
->toContain('aria-label="Download options"')
|
||||
->toStartWith('<div data-md-split-button data-md-size="sm" class="mt-4">')
|
||||
->toMatch('/<button data-md-button="data-md-button"[^>]*data-md-split="leading" wire:click="downloadZip">/')
|
||||
->toMatch('/<button data-md-button="data-md-button"[^>]*data-md-icon-button="data-md-icon-button"[^>]*aria-label="Download options"[^>]*data-md-split="trailing">/')
|
||||
->toContain('role="menu"')
|
||||
->toContain('As a ZIP')
|
||||
->and(substr_count($html, 'wire:click="downloadZip"'))->toBe(1);
|
||||
});
|
||||
|
||||
it('leaves the corners to the group', function () {
|
||||
$html = (string) $this->blade('<x-split-button label="Save"><x-menu-item label="Draft" /></x-split-button>');
|
||||
it('leaves the corners to the pair, growing the inner ones under the finger', function () {
|
||||
$css = ComponentStylesheet::read('split-button');
|
||||
|
||||
preg_match_all('/<button\b[^>]*data-split="[^"]*"[^>]*>/', $html, $halves);
|
||||
|
||||
expect($halves[0])->toHaveCount(2);
|
||||
|
||||
foreach ($halves[0] as $half) {
|
||||
expect($half)->not->toContain('rounded-corner-full')->not->toContain('active:rounded-corner');
|
||||
}
|
||||
expect($css->declarations('[data-md-split-button] [data-md-split]'))->toBe([
|
||||
'--md-split-button-corner' => 'var(--md-split-button-inner)',
|
||||
'border-start-start-radius' => 'var(--md-split-button-corner)',
|
||||
'border-end-start-radius' => 'var(--md-split-button-corner)',
|
||||
'border-start-end-radius' => 'var(--md-split-button-corner)',
|
||||
'border-end-end-radius' => 'var(--md-split-button-corner)',
|
||||
])
|
||||
->and($css->declarations('[data-md-split-button] [data-md-split]:active'))->toBe(['--md-split-button-corner' => 'var(--md-split-button-inner-pressed)'])
|
||||
->and($css->declarations("[data-md-split-button] [data-md-split='trailing'][aria-expanded='true']"))->toBe(['--md-split-button-corner' => 'var(--md-split-button-full)'])
|
||||
->and($css->declarations("[data-md-split-button][data-md-size='xl']"))->toBe([
|
||||
'--md-split-button-inner' => 'var(--md-sys-shape-corner-md)',
|
||||
'--md-split-button-inner-pressed' => 'var(--md-sys-shape-corner-lg-increased)',
|
||||
'--md-split-button-full' => '68px',
|
||||
'--md-split-button-icon' => '50px',
|
||||
'--md-split-button-nudge' => '6px',
|
||||
])
|
||||
->and($css->imports())->toBe(['./button.css']);
|
||||
});
|
||||
|
||||
it('is filled unless another container variant is asked for', function () {
|
||||
@@ -37,7 +46,23 @@ it('is filled unless another container variant is asked for', function () {
|
||||
});
|
||||
|
||||
it('gives the two smallest sizes their narrower inner padding and 48px trailing button', function () {
|
||||
expect((string) $this->blade('<x-split-button label="Save" size="xs"><x-menu-item label="Draft" /></x-split-button>'))
|
||||
->toContain('pe-2.5')
|
||||
->toContain('w-12');
|
||||
$css = ComponentStylesheet::read('split-button');
|
||||
|
||||
expect((string) $this->blade('<x-split-button label="Save" size="xs"><x-menu-item label="Draft" /></x-split-button>'))->toContain('data-md-split-button data-md-size="xs"')
|
||||
->and($css->declarations("[data-md-split-button][data-md-size='xs'] [data-md-split='leading']"))->toBe(['padding-inline-end' => 'var(--md-sys-measurement-space125)'])
|
||||
->and($css->declarations("[data-md-split-button][data-md-size='sm'] [data-md-split='leading']"))->toBe(['padding-inline-end' => '12px'])
|
||||
->and($css->declarations("[data-md-split-button]:is([data-md-size='xs'], [data-md-size='sm']) [data-md-split='trailing']"))->toBe(['inline-size' => 'var(--md-sys-measurement-space600)']);
|
||||
});
|
||||
|
||||
it('nudges its chevron towards the leading half and turns it over on the standard scheme', function () {
|
||||
$css = ComponentStylesheet::read('split-button');
|
||||
|
||||
expect($css->declarations("[data-md-split-button] [data-md-split='trailing'] [data-md-icon]"))->toBe([
|
||||
'inline-size' => 'var(--md-split-button-icon)',
|
||||
'block-size' => 'var(--md-split-button-icon)',
|
||||
'margin-inline-start' => 'calc(-1 * var(--md-split-button-nudge))',
|
||||
'margin-inline-end' => 'var(--md-split-button-nudge)',
|
||||
'transition' => 'rotate var(--md-sys-motion-duration-short) var(--md-sys-motion-easing-standard)',
|
||||
])
|
||||
->and($css->declarations("[data-md-split-button] [data-md-split='trailing'][aria-expanded='true'] [data-md-icon]"))->toBe(['rotate' => '180deg']);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user