From 07c7c8c071849a615492c79a86b7a5cd7b3fe72a Mon Sep 17 00:00:00 2001 From: Josh Bruce Date: Thu, 15 Oct 2026 12:00:43 -0400 Subject: [PATCH] Complete Select attribute and selection acceptance coverage Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- README.md | 51 +++++++++ src/Forms/Select.php | 13 ++- tests/Forms/SelectTest.php | 205 +++++++++++++++++++++++++++++++++++++ 3 files changed, 267 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ced67e8..f2fa7fa 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,57 @@ Overriding the control's `id()` also changes its generated warning ID. Assertion failures identify the relationship, control type and ID, and rendered HTML, alongside PHPUnit's named case and expected/actual diff. +### Select attributes and selection + +`Select::inputProps()` targets the `` in radio/checkbox modes. Use native attributes appropriate to +those elements: for example, `required`, `disabled`, and help references on +either, and `size` on a dropdown. Selects, radios, and checkboxes do not support +`readonly`; the component does not emulate it. + +```php +use Eightfold\HtmlBuilder\Element; +use Eightfold\HtmlBuilder\Forms\Select; + +Select::create('Category', 'category', ['a' => 'A', 'b' => 'B'], 'b') + ->inputProps('required required', 'disabled disabled'); + +Select::create('Categories', 'categories[]', ['a' => 'A', 'b' => 'B'], ['a', 'b']) + ->multiple() + ->inputProps('size 4', 'aria-describedby categories-help') + ->content(Element::p('Choose one or more.')->props('id categories-help')); +``` + +The default mode is a single dropdown. `dropdown()` and `radio()` consider only +the first supplied selection; `multiple()` and `checkbox()` select every matching +option. Unmatched values select nothing in the generated markup (a native +single dropdown may still display its first option by default). Option matching +uses strings, including for numeric option keys. + +Dropdowns (including multiple dropdowns) and radio groups preserve the supplied +field name exactly. For PHP array submissions, supply a name such as +`categories[]` yourself. Checkbox groups retain the existing convention of +appending `[]` unless the name already ends with it. + +`wrapperProps()` targets the dropdown's `
` or the group's `
`. +For native group-wide disabling, use +`->checkbox()->wrapperProps('disabled disabled')` (or `radio()`); the browser +disables descendants through fieldset semantics, without rewriting the inputs. +Disabling a dropdown instead requires `inputProps('disabled disabled')`. +`labelProps()` targets the dropdown's label or the group's legend. + +`inputProps('required required')` on a radio group uses native radio-group +validation. On a checkbox group it makes **every checkbox required**, not +"at least one checkbox"; the component provides no generic group-required rule. + +Generated control IDs and submitted names take precedence over `inputProps()`; +group input types and option values are also generated. `checked` and `multiple` +properties supplied through `inputProps()` are ignored: use the `selected` +argument and explicit mode methods instead. A warning forces +`aria-invalid="true"` and merges help references as described above. Without a +warning, caller-supplied guidance and invalid-state properties remain unchanged. +Other repeated properties use the element builder's normal last-value behavior. + ## Performance The `/comparisons` folder can be used to run performance tests locally to compare the same output using: diff --git a/src/Forms/Select.php b/src/Forms/Select.php index ec2bab4..aa55909 100644 --- a/src/Forms/Select.php +++ b/src/Forms/Select.php @@ -157,10 +157,19 @@ private function warningMessage(): string */ private function inputProperties(bool $noWarning): array { + $properties = array_values(array_filter( + $this->inputProperties, + static fn(string $property): bool => !in_array( + explode(' ', $property, 2)[0], + ['checked', 'multiple'], + true + ) + )); + return ($noWarning) - ? $this->inputProperties + ? $properties : ControlProperties::withWarning( - $this->inputProperties, + $properties, $this->id . '-warning' ); } diff --git a/tests/Forms/SelectTest.php b/tests/Forms/SelectTest.php index 19d7faa..fa71f59 100644 --- a/tests/Forms/SelectTest.php +++ b/tests/Forms/SelectTest.php @@ -4,14 +4,219 @@ namespace Eightfold\HtmlBuilder\Tests; use PHPUnit\Framework\Attributes\Test; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; +use Dom\HTMLDocument; +use Dom\XPath; + use Eightfold\HtmlBuilder\Forms\Select; use Eightfold\HtmlBuilder\Element; class SelectTest extends TestCase { + /** @return iterable */ + public static function modes(): iterable + { + foreach (['dropdown', 'multiple', 'radio', 'checkbox'] as $mode) { + yield $mode => [$mode]; + } + } + + /** + * @return iterable, list}> + */ + public static function selections(): iterable + { + foreach (['dropdown', 'multiple', 'radio', 'checkbox'] as $mode) { + yield $mode . ': empty' => [$mode, [], []]; + yield $mode . ': scalar' => [$mode, 'b', ['b']]; + yield $mode . ': unmatched' => [$mode, ['missing'], []]; + yield $mode . ': numeric key' => [$mode, ['0'], ['0']]; + yield $mode . ': array' => [ + $mode, + ['b', 'a'], + in_array($mode, ['multiple', 'checkbox'], true) ? ['a', 'b'] : ['b'] + ]; + yield $mode . ': unmatched first value' => [ + $mode, + ['missing', 'b'], + in_array($mode, ['multiple', 'checkbox'], true) ? ['b'] : [] + ]; + } + } + + /** + * @param string|list $selected + * @param list $expected + */ + #[Test] + #[DataProvider('selections')] + public function selection_is_driven_by_mode_and_selected_values( + string $mode, + string|array $selected, + array $expected + ): void { + $control = Select::create( + 'Categories', + 'categories', + ['a' => 'A', 'b' => 'B', '0' => 'Zero'], + $selected + ); + $xpath = self::controlXpath($control->$mode()->inputProps('checked checked', 'multiple multiple')); + $nodes = $xpath->query( + '//*[local-name()="option" and @selected or local-name()="input" and @checked]' + ); + parent::assertNotFalse($nodes); + $actual = []; + foreach ($nodes as $node) { + $actual[] = $node->getAttribute('value'); + } + parent::assertSame( + $expected, + $actual, + $mode . ': inputProps must not override selection; single modes use only the first supplied value.' + ); + parent::assertSame( + $mode === 'multiple' ? 1 : 0, + $xpath->query('//*[@multiple]')->length, + $mode . ': only explicit multiple-dropdown mode may emit multiple.' + ); + } + + #[Test] + #[DataProvider('modes')] + public function native_attributes_target_controls_and_preserve_generated_identity(string $mode): void + { + $control = Select::create('Categories', 'categories', ['a' => 'A', 'b' => 'B']); + $isGroup = in_array($mode, ['radio', 'checkbox'], true); + $xpath = self::controlXpath( + $control->$mode()->id('custom')->inputProps( + 'id wrong', + 'name wrong', + 'type wrong', + 'value wrong', + 'required required', + 'disabled disabled', + 'aria-describedby category-help', + ...($isGroup ? [] : ['size 4']) + )->content(Element::p('Help')->props('id category-help')) + ->wrapperProps('class wrapper')->labelProps('class caption') + ); + $controls = $xpath->query('//*[local-name()="input" or local-name()="select"]'); + parent::assertNotFalse($controls); + parent::assertCount($isGroup ? 2 : 1, $controls); + foreach ($controls as $index => $control) { + $id = $isGroup ? 'custom-option-' . $index : 'custom'; + parent::assertSame($id, $control->getAttribute('id'), $mode . ': generated ID must win.'); + parent::assertSame( + $mode === 'checkbox' ? 'categories[]' : 'categories', + $control->getAttribute('name'), + $mode . ': generated submitted name must win.' + ); + parent::assertTrue($control->hasAttribute('required'), $mode . ': required must reach the control.'); + parent::assertTrue($control->hasAttribute('disabled'), $mode . ': disabled must reach the control.'); + if (!$isGroup) { + parent::assertSame('4', $control->getAttribute('size'), 'Dropdown size must reach the select.'); + } + parent::assertSame('category-help', $control->getAttribute('aria-describedby')); + parent::assertFalse($control->hasAttribute('aria-invalid'), 'Help alone must not imply an invalid value.'); + parent::assertSame( + 1, + $xpath->query('//*[local-name()="label" and @for="' . $id . '"]')->length, + $mode . ': each control must retain its associated label.' + ); + if ($isGroup) { + parent::assertSame($mode, $control->getAttribute('type'), 'Generated input type must win.'); + parent::assertSame(['a', 'b'][$index], $control->getAttribute('value'), 'Option value must win.'); + } + } + parent::assertSame(0, $xpath->query('//*[@class="wrapper" and (@required or @disabled or @size or @aria-describedby)]')->length); + parent::assertSame( + 1, + $xpath->query('//*[@class="wrapper" and local-name()="' . ($isGroup ? 'fieldset' : 'div') . '"]')->length + ); + parent::assertSame( + 1, + $xpath->query('//*[@class="caption" and local-name()="' . ($isGroup ? 'legend' : 'label') . '"]')->length + ); + } + + #[Test] + public function switching_from_multiple_to_dropdown_restores_single_selection(): void + { + $control = Select::create('Categories', 'categories', ['a' => 'A', 'b' => 'B'], ['b', 'a']); + $xpath = self::controlXpath($control->multiple()->dropdown()); + parent::assertSame(0, $xpath->query('//*[@multiple]')->length, 'dropdown() must remove multiple mode.'); + parent::assertSame(1, $xpath->query('//*[local-name()="option" and @selected]')->length); + parent::assertSame( + 'b', + $xpath->evaluate('string(//*[local-name()="option" and @selected]/@value)'), + 'Returning to dropdown mode must select only the first supplied value.' + ); + } + + /** @return iterable */ + public static function submittedNames(): iterable + { + foreach (['categories', 'categories[]'] as $name) { + foreach (self::modes() as [$mode]) { + yield $mode . ': ' . $name => [ + $mode, + $name, + $mode === 'checkbox' ? 'categories[]' : $name + ]; + } + } + } + + #[Test] + #[DataProvider('submittedNames')] + public function submitted_names_preserve_existing_conventions( + string $mode, + string $name, + string $expected + ): void { + $control = Select::create('Categories', $name, ['a' => 'A']); + $xpath = self::controlXpath($control->$mode()); + parent::assertSame( + $expected, + $xpath->evaluate('string(//*[local-name()="input" or local-name()="select"]/@name)'), + $mode . ': only checkbox groups append [], and never twice.' + ); + } + + #[Test] + #[DataProvider('modes')] + public function group_disabling_and_required_attributes_follow_native_semantics(string $mode): void + { + $control = Select::create('Categories', 'categories', ['a' => 'A', 'b' => 'B']); + $xpath = self::controlXpath( + $control->$mode()->wrapperProps('disabled disabled')->inputProps('required required') + ); + $isGroup = in_array($mode, ['radio', 'checkbox'], true); + parent::assertSame($isGroup ? 1 : 0, $xpath->query('//*[local-name()="fieldset" and @disabled]')->length); + parent::assertSame( + $isGroup ? 2 : 1, + $xpath->query('//*[local-name()="input" and @required or local-name()="select" and @required]')->length, + 'required is forwarded to each control, not interpreted as a group validation rule.' + ); + parent::assertSame( + 0, + $xpath->query('//*[local-name()="input" and @disabled or local-name()="select" and @disabled]')->length, + 'Group disabling belongs on the fieldset; member attributes must not be rewritten.' + ); + } + + private static function controlXpath(Select $control): XPath + { + return new XPath(HTMLDocument::createFromString( + 'Select test' + . $control . '' + )); + } + #[Test] public function ticket_36(): void // phpcs: ignore {