Skip to content

Commit e891612

Browse files
committed
Add :is modifier for simple value comparisons
1 parent 7dfc0ac commit e891612

11 files changed

Lines changed: 250 additions & 12 deletions

File tree

README.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,11 +220,28 @@ StimulusX provides the following built-in modifiers:
220220
* `:downcase` - transform text to lowercase
221221
* `:strip` - strip leading and trailing whitespace
222222
* `:not` - negate (invert) a boolean value
223+
* `:is(<value>)` - performs a value comparison. See below for details.
223224

224225
> [!TIP]
225226
> _If you need to you can add your own **custom modifiers** -
226227
see [the section on extending StimulusX](#extending) for details._
227228

229+
#### `:is(<value>)` modifier
230+
231+
The `:is` modifier compares the resolved property value with the `<value>` provided within the parentheses, returning `true` if they match and `false` if not.
232+
233+
It is handy for using with [boolean attribute bindings](#boolean-attributes) to conditionally add an attributes based on `String` or `Number` comparisons.
234+
235+
```html
236+
<input data-bind-attr="disabled~workflow#status:is('complete')">
237+
```
238+
239+
> [!NOTE]
240+
> _The `:is` modifier only supports simple `String`, `Number` or `Boolean` comparisons.
241+
242+
243+
244+
228245
<h2 id="attribute-bindings">Attribute bindings</h2>
229246

230247
Attribute bindings connect **HTML attribute values** to **controller properties**, and ensure that the attribute value is automatically updated so as to stay in sync with the value of the controller property at all times.

dist/stimulus-x.js

Lines changed: 40 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

dist/stimulus-x.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/directives.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -115,7 +115,7 @@ function toParsedDirectives({ name, value }) {
115115
const type = name.match(matchedAttributeRegex())[1];
116116
const bindingExpressions = value
117117
.trim()
118-
.split(/\s+/)
118+
.split(/\s+(?![^\(]*\))/) // split string on all spaces not contained in parentheses
119119
.filter((e) => e);
120120

121121
return bindingExpressions.map((bindingExpression) => {

src/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { directive } from "./directives";
44
import { nextTick } from "./scheduler";
55

66
import "./modifiers/downcase";
7+
import "./modifiers/is";
78
import "./modifiers/not";
89
import "./modifiers/strip";
910
import "./modifiers/upcase";

src/modifiers.js

Lines changed: 30 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,18 @@ export function modifier(name, handler) {
99

1010
export function applyModifiers(value, modifiers = []) {
1111
return modifiers.reduce((value, modifier) => {
12-
if (modifierExists(modifier)) {
13-
return applyModifier(modifier, value);
12+
const { name, args } = parseModifierNameAndArguments(modifier);
13+
if (modifierExists(name)) {
14+
return applyModifier(value, name, args);
1415
} else {
1516
console.error(`Unknown modifier '${modifier}'`);
1617
return value;
1718
}
1819
}, value);
1920
}
2021

21-
function applyModifier(name, value) {
22-
return getModifier(name).handler(value);
22+
function applyModifier(value, name, args = []) {
23+
return getModifier(name).handler(value, args);
2324
}
2425

2526
function modifierExists(name) {
@@ -29,3 +30,28 @@ function modifierExists(name) {
2930
function getModifier(name) {
3031
return modifierHandlers.find((modifier) => modifier.name === name);
3132
}
33+
34+
function parseModifierNameAndArguments(modifier) {
35+
const matches = modifier.match(/^([^\(]+)(?=\((?=(.*)\)$)|$)/);
36+
37+
if (matches && typeof matches[2] !== "undefined") {
38+
const argStr = matches[2].trim();
39+
const firstChar = argStr[0];
40+
const lastChar = argStr[argStr.length - 1];
41+
let argValue = null;
42+
43+
if (
44+
(firstChar === "'" && lastChar === "'") ||
45+
(firstChar === "`" && lastChar === "`") ||
46+
(firstChar === `"` && lastChar === `"`)
47+
) {
48+
argValue = argStr.slice(1, argStr.length - 1);
49+
} else {
50+
argValue = JSON.parse(argStr);
51+
}
52+
53+
return { name: matches[1], args: [argValue] };
54+
} else {
55+
return { name: modifier, args: [] };
56+
}
57+
}

src/modifiers/is.js

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { modifier } from "../modifiers";
2+
import { isEqual } from "../utils";
3+
4+
modifier("is", (value, args = []) => {
5+
if (args.length === 0) {
6+
console.warn("Missing argument for `:is` modifier");
7+
return false;
8+
} else {
9+
return isEqual(value, args[0]);
10+
}
11+
});

src/utils.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,3 +17,12 @@ export function walk(el, callback) {
1717
node = node.nextElementSibling;
1818
}
1919
}
20+
21+
export function isEqual(x, y) {
22+
const ok = Object.keys,
23+
tx = typeof x,
24+
ty = typeof y;
25+
return x && y && tx === "object" && tx === ty
26+
? ok(x).length === ok(y).length && ok(x).every((key) => isEqual(x[key], y[key]))
27+
: x === y;
28+
}

test/modifiers/is.test.js

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import { Controller } from "@hotwired/stimulus";
2+
import { createTestContext } from "../support/test-context";
3+
4+
let context = await createTestContext();
5+
6+
afterAll(() => context.teardown());
7+
8+
describe("`is` modifier", async () => {
9+
beforeAll(() =>
10+
context.subject(
11+
class extends Controller {
12+
static values = {
13+
string: {
14+
type: String,
15+
default: "string with spaces",
16+
},
17+
integer: {
18+
type: Number,
19+
default: 12345,
20+
},
21+
float: {
22+
type: Number,
23+
default: 12.345,
24+
},
25+
booleanTrue: {
26+
type: Boolean,
27+
default: true,
28+
},
29+
booleanFalse: {
30+
type: Boolean,
31+
default: false,
32+
},
33+
};
34+
}
35+
)
36+
);
37+
38+
describe("string comparisons", () => {
39+
test("single quoted string", async () => {
40+
const { getTestElement } = await context.testDOM(`
41+
<div data-controller="subject">
42+
<div data-bind-attr="hidden~subject#stringValue:is('string with spaces')" data-test-element="target1"></div>
43+
<div data-bind-attr="hidden~subject#stringValue:is('foo')" data-test-element="target2"></div>
44+
</div>
45+
`);
46+
47+
expect(getTestElement("target1").hidden).toBe(true);
48+
expect(getTestElement("target2").hidden).toBe(false);
49+
});
50+
51+
test("double quoted string", async () => {
52+
const { getTestElement } = await context.testDOM(`
53+
<div data-controller="subject">
54+
<div data-bind-attr='hidden~subject#stringValue:is("string with spaces")' data-test-element="target1"></div>
55+
<div data-bind-attr='hidden~subject#stringValue:is("foo")' data-test-element="target2"></div>
56+
</div>
57+
`);
58+
59+
expect(getTestElement("target1").hidden).toBe(true);
60+
expect(getTestElement("target2").hidden).toBe(false);
61+
});
62+
});
63+
64+
describe("number comparisons", () => {
65+
test("integer", async () => {
66+
const { getTestElement } = await context.testDOM(`
67+
<div data-controller="subject">
68+
<div data-bind-attr="hidden~subject#integerValue:is(12345)" data-test-element="target1"></div>
69+
<div data-bind-attr="hidden~subject#integerValue:is(54321)" data-test-element="target2"></div>
70+
</div>
71+
`);
72+
73+
expect(getTestElement("target1").hidden).toBe(true);
74+
expect(getTestElement("target2").hidden).toBe(false);
75+
});
76+
77+
test("float", async () => {
78+
const { getTestElement } = await context.testDOM(`
79+
<div data-controller="subject">
80+
<div data-bind-attr="hidden~subject#floatValue:is(12.345)" data-test-element="target1"></div>
81+
<div data-bind-attr="hidden~subject#floatValue:is(543.21)" data-test-element="target2"></div>
82+
</div>
83+
`);
84+
85+
expect(getTestElement("target1").hidden).toBe(true);
86+
expect(getTestElement("target2").hidden).toBe(false);
87+
});
88+
});
89+
90+
describe("boolean comparisons", () => {
91+
test("true", async () => {
92+
const { getTestElement } = await context.testDOM(`
93+
<div data-controller="subject">
94+
<div data-bind-attr="hidden~subject#booleanTrueValue:is(true)" data-test-element="target1"></div>
95+
<div data-bind-attr="hidden~subject#booleanFalseValue:is(true)" data-test-element="target2"></div>
96+
<div data-bind-attr="hidden~subject#booleanTrueValue:is( true )" data-test-element="target3"></div>
97+
</div>
98+
`);
99+
100+
expect(getTestElement("target1").hidden).toBe(true);
101+
expect(getTestElement("target2").hidden).toBe(false);
102+
expect(getTestElement("target3").hidden).toBe(true);
103+
});
104+
105+
test("false", async () => {
106+
const { getTestElement } = await context.testDOM(`
107+
<div data-controller="subject">
108+
<div data-bind-attr="hidden~subject#booleanTrueValue:is(false)" data-test-element="target1"></div>
109+
<div data-bind-attr="hidden~subject#booleanFalseValue:is(false)" data-test-element="target2"></div>
110+
<div data-bind-attr="hidden~subject#booleanFalseValue:is( false )" data-test-element="target3"></div>
111+
</div>
112+
`);
113+
114+
expect(getTestElement("target1").hidden).toBe(false);
115+
expect(getTestElement("target2").hidden).toBe(true);
116+
expect(getTestElement("target3").hidden).toBe(true);
117+
});
118+
119+
describe("multiple descriptors", () => {
120+
test("true", async () => {
121+
const { getTestElement } = await context.testDOM(`
122+
<div data-controller="subject">
123+
<input
124+
data-bind-attr="
125+
hidden~subject#stringValue:is('string with spaces')
126+
disabled~subject#integerValue:is(12345)
127+
"
128+
data-test-element="target">
129+
130+
</div>
131+
`);
132+
133+
expect(getTestElement("target").hidden).toBe(true);
134+
expect(getTestElement("target").disabled).toBe(true);
135+
});
136+
});
137+
});
138+
});

test/opt-in.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Application, Controller } from "@hotwired/stimulus";
2-
import StimulusX from "../dist/stimulus-x";
2+
import StimulusX from "../src";
33
import { isReactive } from "../src/reactivity";
44
import { nextTick } from "./support/helpers";
55

0 commit comments

Comments
 (0)