Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<select>` in dropdown modes and **every**
member `<input>` 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 `<div>` or the group's `<fieldset>`.
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:
Expand Down
13 changes: 11 additions & 2 deletions src/Forms/Select.php
Original file line number Diff line number Diff line change
Expand Up @@ -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'
);
}
Expand Down
205 changes: 205 additions & 0 deletions tests/Forms/SelectTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{string}> */
public static function modes(): iterable
{
foreach (['dropdown', 'multiple', 'radio', 'checkbox'] as $mode) {
yield $mode => [$mode];
}
}

/**
* @return iterable<string, array{string, string|list<string>, list<string>}>
*/
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<string> $selected
* @param list<string> $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<string, array{string, string, string}> */
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(
'<!doctype html><html><head><title>Select test</title></head><body>'
. $control . '</body></html>'
));
}

#[Test]
public function ticket_36(): void // phpcs: ignore
{
Expand Down
Loading