Skip to content

Commit 159d993

Browse files
authored
feature: edit multiple classes at once (#517)
* feature: edit multiple classes at once closes #412 * tweak: docblock return type * test: improve coverage
1 parent bd65cdd commit 159d993

File tree

3 files changed

+226
-19
lines changed

3 files changed

+226
-19
lines changed

src/HTMLAttributeBinder.php

Lines changed: 106 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -33,8 +33,9 @@ public function bind(
3333
$element = $element->documentElement;
3434
}
3535

36-
$bindValue = $this->normalizeBindValue($value);
36+
$bindValue = $this->standardiseBindValue($value);
3737
$attributesToRemove = [];
38+
$attributeUpdates = [];
3839
foreach($element->attributes as $attributeName => $attribute) {
3940
/** @var Attr $attribute */
4041
$this->markBoundElement($element, $attribute, $key, $bindValue);
@@ -43,7 +44,7 @@ public function bind(
4344
}
4445

4546
$bindProperty = $this->getBindProperty($element, $attributeName);
46-
$modifier = $this->resolveModifier($key, $attribute->value);
47+
[$modifier, $remainingExpressions] = $this->resolveModifier($key, $attribute->value);
4748
if($modifier === false) {
4849
continue;
4950
}
@@ -57,10 +58,19 @@ public function bind(
5758
$this->appendDebugInfo($element, $bindProperty);
5859
$element->setAttribute("data-bound", "");
5960
if(!$attribute->ownerElement->hasAttribute("data-rebind")) {
60-
$attributesToRemove[] = $attributeName;
61+
if($remainingExpressions === []) {
62+
$attributesToRemove[] = $attributeName;
63+
}
64+
else {
65+
$attributeUpdates[$attributeName] = implode("; ", $remainingExpressions);
66+
}
6167
}
6268
}
6369

70+
foreach($attributeUpdates as $attributeName => $attributeValue) {
71+
$element->setAttribute($attributeName, $attributeValue);
72+
}
73+
6474
foreach($attributesToRemove as $attributeName) {
6575
$element->removeAttribute($attributeName);
6676
}
@@ -96,7 +106,7 @@ public function expandAttributes(Element $element):void {
96106
}
97107
}
98108

99-
private function normalizeBindValue(mixed $value):mixed {
109+
private function standardiseBindValue(mixed $value):mixed {
100110
if(is_scalar($value) || is_iterable($value)) {
101111
return $value;
102112
}
@@ -156,26 +166,54 @@ private function getBindProperty(
156166
return substr($attributeName, strpos($attributeName, ":") + 1);
157167
}
158168

169+
/** @return array{0:string|false|null, 1:array<int, string>} */
159170
private function resolveModifier(
160171
?string $key,
161172
string $attributeValue,
162-
):string|false|null {
173+
):array {
163174
if(is_null($key)) {
164-
return $attributeValue === ""
165-
? null
166-
: false;
175+
return [
176+
$attributeValue === ""
177+
? null
178+
: false,
179+
[],
180+
];
181+
}
182+
183+
$matchingExpression = null;
184+
$remainingExpressions = [];
185+
foreach($this->splitBindExpressions($attributeValue) as $expression) {
186+
$trimmedExpression = ltrim($expression, ":!?");
187+
$trimmedExpression = strtok($trimmedExpression, " ");
188+
$bindKey = $this->extractBindKey($trimmedExpression);
189+
if(is_null($matchingExpression) && ($key === $bindKey || $bindKey === "@")) {
190+
$matchingExpression = $expression;
191+
continue;
192+
}
193+
194+
$remainingExpressions[] = $expression;
167195
}
168196

169-
$trimmedAttrValue = ltrim($attributeValue, ":!?");
170-
$trimmedAttrValue = strtok($trimmedAttrValue, " ");
171-
$bindKey = $this->extractBindKey($trimmedAttrValue);
172-
if($key !== $bindKey && $bindKey !== "@") {
173-
return false;
197+
if(is_null($matchingExpression)) {
198+
return [false, []];
174199
}
175200

176-
return $attributeValue !== $trimmedAttrValue
177-
? $attributeValue
178-
: null;
201+
return [
202+
$matchingExpression !== ltrim($matchingExpression, ":!?")
203+
? $matchingExpression
204+
: null,
205+
$remainingExpressions,
206+
];
207+
}
208+
209+
/** @return array<int, string> */
210+
private function splitBindExpressions(string $attributeValue):array {
211+
return array_values(
212+
array_filter(
213+
array_map("trim", explode(";", $attributeValue)),
214+
fn(string $expression):bool => $expression !== "",
215+
),
216+
);
179217
}
180218

181219
private function defaultListBindingName(Element $element):string {
@@ -312,7 +350,9 @@ private function bindClassProperty(
312350
return;
313351
}
314352

315-
$element->classList->add($bindValue);
353+
foreach($this->prepareTokenListValues($bindValue) as $className) {
354+
$element->classList->add($className);
355+
}
316356
}
317357

318358
private function bindRemoveProperty(
@@ -368,11 +408,12 @@ private function handleModifier(
368408
switch($modifierChar) {
369409
case ":":
370410
$tokenList = $this->getTokenList($element, $attribute);
411+
$tokenNames = $this->resolveTokenNames($modifier, $bindValue);
371412
if($bindValue) {
372-
$tokenList->add($modifierValue);
413+
$tokenList->add(...$tokenNames);
373414
}
374415
else {
375-
$tokenList->remove($modifierValue);
416+
$tokenList->remove(...$tokenNames);
376417
}
377418
break;
378419

@@ -427,11 +468,57 @@ private function extractModifierExpression(string $modifier):string {
427468
return strtok($modifierValue, " ") ?: "";
428469
}
429470

471+
/** @return array<int, string> */
472+
private function resolveTokenNames(string $modifier, mixed $bindValue):array {
473+
$tokenNames = $this->extractModifierTokens($modifier);
474+
if($tokenNames) {
475+
return $tokenNames;
476+
}
477+
478+
if(is_bool($bindValue)) {
479+
$bindExpression = $this->extractModifierExpression($modifier);
480+
return [$this->extractBindKey($bindExpression)];
481+
}
482+
483+
return $this->prepareTokenListValues($bindValue);
484+
}
485+
486+
/** @return array<int, string> */
487+
private function extractModifierTokens(string $modifier):array {
488+
$modifierValue = substr($modifier, 1);
489+
$modifierValue = ltrim($modifierValue, "!");
490+
$spacePos = strpos($modifierValue, " ");
491+
if($spacePos === false) {
492+
return [];
493+
}
494+
495+
return $this->prepareTokenListValues(substr($modifierValue, $spacePos + 1));
496+
}
497+
430498
private function extractCondition(string $bindExpression):?string {
431499
$parts = explode("=", $bindExpression, 2);
432500
return $parts[1] ?? null;
433501
}
434502

503+
/** @return array<int, string> */
504+
private function prepareTokenListValues(mixed $bindValue):array {
505+
if(is_iterable($bindValue)) {
506+
$tokenList = [];
507+
foreach($bindValue as $tokenValue) {
508+
array_push($tokenList, ...$this->prepareTokenListValues($tokenValue));
509+
}
510+
511+
return array_values(array_unique($tokenList));
512+
}
513+
514+
if(!is_scalar($bindValue) && !$bindValue instanceof \Stringable) {
515+
return [];
516+
}
517+
518+
$tokenList = preg_split('/\s+/', trim((string)$bindValue)) ?: [];
519+
return array_values(array_filter($tokenList, fn(string $token):bool => $token !== ""));
520+
}
521+
435522
private function valueMatchesCondition(mixed $bindValue, string $condition):bool {
436523
if(is_bool($bindValue)) {
437524
$bindValue = (int)$bindValue;

test/phpunit/HTMLAttributeBinderTest.php

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,113 @@ public function testBind_modifierColon():void {
8787
self::assertTrue($img->classList->contains("size-large"));
8888
}
8989

90+
public function testBind_classProperty_multipleClassNamesFromString():void {
91+
$document = new HTMLDocument(HTMLPageContent::HTML_MULTI_CLASS_BINDING);
92+
$sut = new HTMLAttributeBinder();
93+
$div = $document->getElementById("div1");
94+
95+
$sut->bind("statusClasses", "featured promoted", $div);
96+
97+
self::assertTrue($div->classList->contains("panel"));
98+
self::assertTrue($div->classList->contains("featured"));
99+
self::assertTrue($div->classList->contains("promoted"));
100+
}
101+
102+
public function testBind_classProperty_multipleClassNamesFromIterable():void {
103+
$document = new HTMLDocument(HTMLPageContent::HTML_MULTI_CLASS_BINDING);
104+
$sut = new HTMLAttributeBinder();
105+
$div = $document->getElementById("div1");
106+
107+
$sut->bind("statusClasses", ["featured promoted", "compact"], $div);
108+
109+
self::assertTrue($div->classList->contains("featured"));
110+
self::assertTrue($div->classList->contains("promoted"));
111+
self::assertTrue($div->classList->contains("compact"));
112+
}
113+
114+
public function testBind_classProperty_iterableIgnoresNonStringableValues():void {
115+
$document = new HTMLDocument(HTMLPageContent::HTML_MULTI_CLASS_BINDING);
116+
$sut = new HTMLAttributeBinder();
117+
$div = $document->getElementById("div1");
118+
119+
$sut->bind("statusClasses", ["featured", new \stdClass(), "compact"], $div);
120+
121+
self::assertTrue($div->classList->contains("featured"));
122+
self::assertTrue($div->classList->contains("compact"));
123+
self::assertFalse($div->classList->contains("stdClass"));
124+
}
125+
126+
public function testBind_modifierColon_multipleExplicitClassNames():void {
127+
$document = new HTMLDocument(HTMLPageContent::HTML_MULTI_CLASS_BINDING);
128+
$sut = new HTMLAttributeBinder();
129+
$div = $document->getElementById("div2");
130+
131+
$sut->bind("isSelected", true, $div);
132+
133+
self::assertTrue($div->classList->contains("selected-image"));
134+
self::assertTrue($div->classList->contains("featured"));
135+
}
136+
137+
public function testBind_modifierColon_usesBoundValueWhenNoExplicitClassNames():void {
138+
$document = new HTMLDocument(HTMLPageContent::HTML_MULTI_CLASS_BINDING);
139+
$sut = new HTMLAttributeBinder();
140+
$div = $document->getElementById("div3");
141+
142+
$sut->bind("statusClasses", "featured promoted", $div);
143+
144+
self::assertTrue($div->classList->contains("featured"));
145+
self::assertTrue($div->classList->contains("promoted"));
146+
}
147+
148+
public function testBind_modifierColon_removesMultipleClassNamesAtOnce():void {
149+
$document = new HTMLDocument(HTMLPageContent::HTML_MULTI_CLASS_BINDING);
150+
$sut = new HTMLAttributeBinder();
151+
$div = $document->getElementById("div2");
152+
$div->classList->add("selected-image");
153+
$div->classList->add("featured");
154+
155+
$sut->bind("isSelected", false, $div);
156+
157+
self::assertFalse($div->classList->contains("selected-image"));
158+
self::assertFalse($div->classList->contains("featured"));
159+
}
160+
161+
public function testBind_modifierColon_multipleExpressionsCanBeBundled():void {
162+
$document = new HTMLDocument(HTMLPageContent::HTML_MULTI_CLASS_BINDING);
163+
$sut = new HTMLAttributeBinder();
164+
$div = $document->getElementById("div4");
165+
166+
$sut->bind("isSelected", true, $div);
167+
$sut->bind("isAdmin", true, $div);
168+
169+
self::assertTrue($div->classList->contains("selected"));
170+
self::assertTrue($div->classList->contains("admin"));
171+
}
172+
173+
public function testBind_modifierBundle_preservesRemainingExpressionsWithoutRebind():void {
174+
$document = new HTMLDocument(HTMLPageContent::HTML_MULTI_CLASS_BINDING);
175+
$sut = new HTMLAttributeBinder();
176+
$div = $document->getElementById("div4");
177+
178+
$sut->bind("isSelected", true, $div);
179+
180+
self::assertSame(":isAdmin admin", $div->getAttribute("data-bind:class"));
181+
}
182+
183+
public function testBind_modifierQuestion_multipleExpressionsCanBeBundled():void {
184+
$document = new HTMLDocument(HTMLPageContent::HTML_MULTI_CLASS_BINDING);
185+
$sut = new HTMLAttributeBinder();
186+
$button = $document->getElementById("btn3");
187+
188+
$sut->bind("isBusy", false, $button);
189+
self::assertSame("?isLocked", $button->getAttribute("data-bind:disabled"));
190+
self::assertFalse($button->disabled);
191+
192+
$sut->bind("isLocked", true, $button);
193+
self::assertTrue($button->disabled);
194+
self::assertFalse($button->hasAttribute("data-bind:disabled"));
195+
}
196+
90197
public function testBind_modifierQuestion():void {
91198
$document = new HTMLDocument(HTMLPageContent::HTML_DIFFERENT_BIND_PROPERTIES);
92199
$sut = new HTMLAttributeBinder();

test/phpunit/TestHelper/HTMLPageContent.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,19 @@ class HTMLPageContent {
184184
<button id="btn1" data-bind:disabled="?isBtn1Disabled" data-rebind></button>
185185
<button id="btn2" data-bind:disabled="?!isBtn2Enabled" data-rebind></button>
186186
</form>
187+
HTML;
188+
189+
const HTML_MULTI_CLASS_BINDING = <<<HTML
190+
<!doctype html>
191+
<div id="div1" class="panel" data-bind:class="statusClasses"></div>
192+
193+
<div id="div2" class="panel" data-bind:class=":isSelected selected-image featured" data-rebind></div>
194+
195+
<div id="div3" class="panel" data-bind:class=":statusClasses" data-rebind></div>
196+
197+
<div id="div4" class="panel" data-bind:class=":isSelected selected; :isAdmin admin"></div>
198+
199+
<button id="btn3" data-bind:disabled="?isBusy; ?isLocked"></button>
187200
HTML;
188201

189202
const HTML_SIMPLE_BOOLEAN = <<<HTML

0 commit comments

Comments
 (0)