diff --git a/docs/reference/values.md b/docs/reference/values.md
index 8a1aa5de..8638d2f8 100644
--- a/docs/reference/values.md
+++ b/docs/reference/values.md
@@ -52,16 +52,35 @@ export default class extends Controller {
## Types
-A value's type is one of `Array`, `Boolean`, `Number`, `Object`, or `String`. The type determines how the value is transcoded between JavaScript and HTML.
+A value's type is one of `Array`, `Boolean`, `DOMTokenList`, `Number`, `Object`, or `String`. The type determines how the value is transcoded between JavaScript and HTML.
| Type | Encoded as… | Decoded as… |
| ------- | ------------------------ | --------------------------------------- |
| Array | `JSON.stringify(array)` | `JSON.parse(value)` |
| Boolean | `boolean.toString()` | `!(value == "0" \|\| value == "false")` |
+| DOMTokenList | `tokens.join(" ")` | Space-separated tokens, deduplicated |
| Number | `number.toString()` | `Number(value.replace(/_/g, ""))` |
| Object | `JSON.stringify(object)` | `JSON.parse(value)` |
| String | Itself | Itself |
+### List Values
+
+Declare a list value with the `DOMTokenList` constructor to store a simple array of string tokens as a space-separated attribute, the same format the platform uses for the `class` attribute and `DOMTokenList` properties like `classList`:
+
+```js
+export default class extends Controller {
+ static values = {
+ permittedTypes: DOMTokenList
+ }
+}
+```
+
+```html
+
+```
+
+Reading a list value returns an array of strings. Like `DOMTokenList`, repeated tokens are deduplicated, keeping the first occurrence. Writing joins the tokens with single spaces; assigning a value that is not an array, or a token that is empty or contains whitespace, throws a `TypeError`. Use the `Array` type instead when your values are arbitrary strings rather than simple tokens. Default values declared for a list value must themselves be valid tokens.
+
## Properties and Attributes
Stimulus automatically generates getter, setter, and existential properties for each value defined in a controller. These properties are linked to data attributes on the controller's element:
@@ -82,6 +101,7 @@ Type | Default value
---- | -------------
Array | `[]`
Boolean | `false`
+DOMTokenList | `[]`
Number | `0`
Object | `{}`
String | `""`
diff --git a/src/core/value_observer.ts b/src/core/value_observer.ts
index 2d12c9cd..abc39260 100644
--- a/src/core/value_observer.ts
+++ b/src/core/value_observer.ts
@@ -90,7 +90,7 @@ export class ValueObserver implements StringMapObserverDelegate {
const value = descriptor.reader(rawValue)
let oldValue = rawOldValue
- if (rawOldValue) {
+ if (rawOldValue !== undefined) {
oldValue = descriptor.reader(rawOldValue)
}
diff --git a/src/core/value_properties.ts b/src/core/value_properties.ts
index c15e2d9a..0038cd5f 100644
--- a/src/core/value_properties.ts
+++ b/src/core/value_properties.ts
@@ -74,7 +74,13 @@ export type ValueDefinitionMap = { [token: string]: ValueTypeDefinition }
export type ValueDefinitionPair = [string, ValueTypeDefinition]
-export type ValueTypeConstant = typeof Array | typeof Boolean | typeof Number | typeof Object | typeof String
+export type ValueTypeConstant =
+ | typeof Array
+ | typeof Boolean
+ | typeof DOMTokenList
+ | typeof Number
+ | typeof Object
+ | typeof String
export type ValueTypeDefault = Array | boolean | number | Object | string
@@ -82,7 +88,7 @@ export type ValueTypeObject = Partial<{ type: ValueTypeConstant; default: ValueT
export type ValueTypeDefinition = ValueTypeConstant | ValueTypeDefault | ValueTypeObject
-export type ValueType = "array" | "boolean" | "number" | "object" | "string"
+export type ValueType = "array" | "boolean" | "list" | "number" | "object" | "string"
function parseValueDefinitionPair([token, typeDefinition]: ValueDefinitionPair, controller?: string): ValueDescriptor {
return valueDescriptorForTokenAndTypeDefinition({
@@ -93,6 +99,8 @@ function parseValueDefinitionPair([token, typeDefinition]: ValueDefinitionPair,
}
export function parseValueTypeConstant(constant?: ValueTypeConstant) {
+ if (typeof DOMTokenList !== "undefined" && constant === DOMTokenList) return "list"
+
switch (constant) {
case Array:
return "array"
@@ -143,14 +151,28 @@ export function parseValueTypeObject(payload: ValueTypeObjectPayload) {
if (onlyType) return typeFromObject
if (onlyDefault) return typeFromDefaultValue
- if (typeFromObject !== typeFromDefaultValue) {
- const propertyPath = controller ? `${controller}.${token}` : token
+ const propertyPath = controller ? `${controller}.${token}` : token
+
+ const defaultValueMatchesType =
+ typeFromObject === typeFromDefaultValue || (typeFromObject === "list" && typeFromDefaultValue === "array")
+ if (!defaultValueMatchesType) {
throw new Error(
`The specified default value for the Stimulus Value "${propertyPath}" must match the defined type "${typeFromObject}". The provided default value of "${typeObject.default}" is of type "${typeFromDefaultValue}".`
)
}
+ if (typeFromObject === "list" && Array.isArray(typeObject.default)) {
+ for (const item of typeObject.default) {
+ const listToken = `${item}`
+ if (listToken.length == 0 || /\s/.test(listToken)) {
+ throw new Error(
+ `The specified default value for the Stimulus Value "${propertyPath}" contains the invalid list token "${listToken}". List tokens must be non-empty and free of whitespace.`
+ )
+ }
+ }
+ }
+
if (fullObject) return typeFromObject
}
@@ -223,6 +245,9 @@ const defaultValuesByType = {
return []
},
boolean: false,
+ get list() {
+ return []
+ },
number: 0,
get object() {
return {}
@@ -247,6 +272,14 @@ const readers: { [type: string]: Reader } = {
return !(value == "0" || String(value).toLowerCase() == "false")
},
+ list(value: string): string[] {
+ const tokens = value
+ .trim()
+ .split(/\s+/)
+ .filter((token) => token.length > 0)
+ return Array.from(new Set(tokens))
+ },
+
number(value: string): number {
return Number(value.replace(/_/g, ""))
},
@@ -271,6 +304,7 @@ type Writer = (value: any) => string
const writers: { [type: string]: Writer } = {
default: writeString,
array: writeJSON,
+ list: writeList,
object: writeJSON,
}
@@ -278,6 +312,26 @@ function writeJSON(value: any) {
return JSON.stringify(value)
}
+function writeList(value: any) {
+ if (!Array.isArray(value)) {
+ throw new TypeError(
+ `expected value of type "list" but instead got value "${value}" of type "${parseValueTypeDefault(value)}"`
+ )
+ }
+
+ const tokens = value.map((item) => `${item}`)
+
+ for (const token of tokens) {
+ if (token.length == 0 || /\s/.test(token)) {
+ throw new TypeError(
+ `expected token of a "list" value to be non-empty and free of whitespace but got "${token}". Use the Array type for values containing arbitrary strings.`
+ )
+ }
+ }
+
+ return tokens.join(" ")
+}
+
function writeString(value: any) {
return `${value}`
}
diff --git a/src/tests/controllers/default_value_controller.ts b/src/tests/controllers/default_value_controller.ts
index 603842c5..88d8bf3f 100644
--- a/src/tests/controllers/default_value_controller.ts
+++ b/src/tests/controllers/default_value_controller.ts
@@ -21,6 +21,10 @@ export class DefaultValueController extends Controller {
defaultArrayFilled: { type: Array, default: [1, 2, 3] },
defaultArrayOverride: [9, 9, 9],
+ defaultList: { type: DOMTokenList, default: [] },
+ defaultListFilled: { type: DOMTokenList, default: ["one", "two"] },
+ defaultListOverride: { type: DOMTokenList, default: ["will", "be", "overridden"] },
+
defaultObject: {},
defaultObjectPerson: { type: Object, default: { name: "David" } },
defaultObjectOverride: { override: "me" },
@@ -60,6 +64,13 @@ export class DefaultValueController extends Controller {
defaultArrayOverrideValue!: { [key: string]: any }
hasDefaultArrayOverrideValue!: boolean
+ defaultListValue!: string[]
+ hasDefaultListValue!: boolean
+ defaultListFilledValue!: string[]
+ hasDefaultListFilledValue!: boolean
+ defaultListOverrideValue!: string[]
+ hasDefaultListOverrideValue!: boolean
+
defaultObjectValue!: object
hasDefaultObjectValue!: boolean
defaultObjectPersonValue!: object
diff --git a/src/tests/controllers/value_controller.ts b/src/tests/controllers/value_controller.ts
index 50bde761..da90668f 100644
--- a/src/tests/controllers/value_controller.ts
+++ b/src/tests/controllers/value_controller.ts
@@ -19,6 +19,7 @@ export class ValueController extends BaseValueController {
missingString: String,
ids: Array,
options: Object,
+ tokens: DOMTokenList,
"time-24hr": Boolean,
}
@@ -26,6 +27,7 @@ export class ValueController extends BaseValueController {
missingStringValue!: string
idsValue!: any[]
optionsValue!: { [key: string]: any }
+ tokensValue!: string[]
time24hrValue!: boolean
loggedNumericValues: number[] = []
@@ -48,4 +50,11 @@ export class ValueController extends BaseValueController {
this.optionsValues.push(value)
this.oldOptionsValues.push(oldValue)
}
+
+ loggedTokensValues: string[][] = []
+ oldLoggedTokensValues: any[] = []
+ tokensValueChanged(value: string[], oldValue: any) {
+ this.loggedTokensValues.push(value)
+ this.oldLoggedTokensValues.push(oldValue)
+ }
}
diff --git a/src/tests/modules/core/default_value_tests.ts b/src/tests/modules/core/default_value_tests.ts
index 924d85f3..dce3f7b0 100644
--- a/src/tests/modules/core/default_value_tests.ts
+++ b/src/tests/modules/core/default_value_tests.ts
@@ -8,6 +8,7 @@ export default class DefaultValueTests extends ControllerTestCase(DefaultValueCo
data-${this.identifier}-default-boolean-override-value="false"
data-${this.identifier}-default-number-override-value="42"
data-${this.identifier}-default-array-override-value="[9,8,7]"
+ data-${this.identifier}-default-list-override-value="expected value"
data-${this.identifier}-default-object-override-value='{"expected":"value"}'
`
@@ -140,6 +141,36 @@ export default class DefaultValueTests extends ControllerTestCase(DefaultValueCo
this.assert.ok(this.controller.hasDefaultArrayOverrideValue)
}
+ // Lists
+
+ "test custom default list values"() {
+ this.assert.deepEqual(this.controller.defaultListValue, [])
+ this.assert.ok(this.controller.hasDefaultListValue)
+ this.assert.deepEqual(this.get("default-list-value"), null)
+
+ this.assert.deepEqual(this.controller.defaultListFilledValue, ["one", "two"])
+ this.assert.ok(this.controller.hasDefaultListFilledValue)
+ this.assert.deepEqual(this.get("default-list-filled-value"), null)
+ }
+
+ "test should be able to set a new value for custom default list values"() {
+ this.assert.deepEqual(this.get("default-list-value"), null)
+ this.assert.deepEqual(this.controller.defaultListValue, [])
+ this.assert.ok(this.controller.hasDefaultListValue)
+
+ this.controller.defaultListValue = ["new", "value"]
+
+ this.assert.deepEqual(this.get("default-list-value"), "new value")
+ this.assert.deepEqual(this.controller.defaultListValue, ["new", "value"])
+ this.assert.ok(this.controller.hasDefaultListValue)
+ }
+
+ "test should override custom default list value with given data-attribute"() {
+ this.assert.deepEqual(this.get("default-list-override-value"), "expected value")
+ this.assert.deepEqual(this.controller.defaultListOverrideValue, ["expected", "value"])
+ this.assert.ok(this.controller.hasDefaultListOverrideValue)
+ }
+
// Objects
"test custom default object values"() {
diff --git a/src/tests/modules/core/value_properties_tests.ts b/src/tests/modules/core/value_properties_tests.ts
index 0c8a583d..d4b8d79a 100644
--- a/src/tests/modules/core/value_properties_tests.ts
+++ b/src/tests/modules/core/value_properties_tests.ts
@@ -16,6 +16,7 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro
this.assert.equal(parseValueTypeConstant(Array), "array")
this.assert.equal(parseValueTypeConstant(Object), "object")
this.assert.equal(parseValueTypeConstant(Number), "number")
+ this.assert.equal(parseValueTypeConstant(DOMTokenList), "list")
this.assert.equal(parseValueTypeConstant("" as any), undefined)
this.assert.equal(parseValueTypeConstant({} as any), undefined)
@@ -94,6 +95,11 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro
this.assert.equal(typeObject({ type: Boolean }), "boolean")
this.assert.equal(typeObject({ default: false }), "boolean")
+ this.assert.equal(typeObject({ type: DOMTokenList }), "list")
+ this.assert.equal(typeObject({ type: DOMTokenList, default: [] }), "list")
+ this.assert.equal(typeObject({ type: DOMTokenList, default: ["a", "b"] }), "list")
+ this.assert.equal(typeObject({ default: ["a", "b"] }), "array")
+
this.assert.throws(() => typeObject({ type: Boolean, default: "something else" }), {
name: "Error",
message: `The specified default value for the Stimulus Value "test.url" must match the defined type "boolean". The provided default value of "something else" is of type "string".`,
@@ -103,6 +109,21 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro
name: "Error",
message: `The specified default value for the Stimulus Value "test.url" must match the defined type "boolean". The provided default value of "true" is of type "string".`,
})
+
+ this.assert.throws(() => typeObject({ type: DOMTokenList, default: "a b" }), {
+ name: "Error",
+ message: `The specified default value for the Stimulus Value "test.url" must match the defined type "list". The provided default value of "a b" is of type "string".`,
+ })
+
+ this.assert.throws(() => typeObject({ type: DOMTokenList, default: ["foo bar"] }), {
+ name: "Error",
+ message: `The specified default value for the Stimulus Value "test.url" contains the invalid list token "foo bar". List tokens must be non-empty and free of whitespace.`,
+ })
+
+ this.assert.throws(() => typeObject({ type: DOMTokenList, default: [""] }), {
+ name: "Error",
+ message: `The specified default value for the Stimulus Value "test.url" contains the invalid list token "". List tokens must be non-empty and free of whitespace.`,
+ })
}
"test parseValueTypeDefinition booleans"() {
@@ -128,6 +149,7 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro
this.assert.equal(typeDefinition({}), "object")
this.assert.equal(typeDefinition(""), "string")
this.assert.equal(typeDefinition([]), "array")
+ this.assert.equal(typeDefinition(DOMTokenList), "list")
this.assert.throws(() => typeDefinition(null))
this.assert.throws(() => typeDefinition(undefined))
@@ -139,12 +161,15 @@ export default class ValuePropertiesTests extends ControllerTestCase(ValueContro
this.assert.deepEqual(defaultValueForDefinition(Object), {})
this.assert.deepEqual(defaultValueForDefinition(Array), [])
this.assert.deepEqual(defaultValueForDefinition(Number), 0)
+ this.assert.deepEqual(defaultValueForDefinition(DOMTokenList), [])
this.assert.deepEqual(defaultValueForDefinition({ type: String }), "")
this.assert.deepEqual(defaultValueForDefinition({ type: Boolean }), false)
this.assert.deepEqual(defaultValueForDefinition({ type: Object }), {})
this.assert.deepEqual(defaultValueForDefinition({ type: Array }), [])
this.assert.deepEqual(defaultValueForDefinition({ type: Number }), 0)
+ this.assert.deepEqual(defaultValueForDefinition({ type: DOMTokenList }), [])
+ this.assert.deepEqual(defaultValueForDefinition({ type: DOMTokenList, default: ["a", "b"] }), ["a", "b"])
this.assert.deepEqual(defaultValueForDefinition({ type: String, default: null }), null)
this.assert.deepEqual(defaultValueForDefinition({ type: Boolean, default: null }), null)
diff --git a/src/tests/modules/core/value_tests.ts b/src/tests/modules/core/value_tests.ts
index fd60ee1a..9c95f7e1 100644
--- a/src/tests/modules/core/value_tests.ts
+++ b/src/tests/modules/core/value_tests.ts
@@ -9,6 +9,7 @@ export default class ValueTests extends ControllerTestCase(ValueController) {
data-${this.identifier}-string-value="ok"
data-${this.identifier}-ids-value="[1,2,3]"
data-${this.identifier}-options-value='{"one":[2,3]}'
+ data-${this.identifier}-tokens-value="one two three"
data-${this.identifier}-time-24hr-value="true">
`
@@ -114,6 +115,46 @@ export default class ValueTests extends ControllerTestCase(ValueController) {
this.assert.throws(() => this.controller.optionsValue)
}
+ "test list values"() {
+ this.assert.deepEqual(this.controller.tokensValue, ["one", "two", "three"])
+
+ this.controller.tokensValue = ["a", "b"]
+ this.assert.deepEqual(this.controller.tokensValue, ["a", "b"])
+ this.assert.deepEqual(this.get("tokens-value"), "a b")
+
+ this.controller.tokensValue.push("c")
+ this.assert.deepEqual(this.controller.tokensValue, ["a", "b"])
+
+ this.set("tokens-value", "b a b c a")
+ this.assert.deepEqual(this.controller.tokensValue, ["b", "a", "c"])
+
+ this.set("tokens-value", " one\t two\n three ")
+ this.assert.deepEqual(this.controller.tokensValue, ["one", "two", "three"])
+
+ this.set("tokens-value", "")
+ this.assert.deepEqual(this.controller.tokensValue, [])
+
+ this.set("tokens-value", " ")
+ this.assert.deepEqual(this.controller.tokensValue, [])
+
+ this.controller.tokensValue = ["x", "y", "x"]
+ this.assert.deepEqual(this.get("tokens-value"), "x y x")
+ this.assert.deepEqual(this.controller.tokensValue, ["x", "y"])
+
+ this.controller.tokensValue = [1, 2] as any
+ this.assert.deepEqual(this.get("tokens-value"), "1 2")
+ this.assert.deepEqual(this.controller.tokensValue, ["1", "2"])
+
+ this.controller.tokensValue = []
+ this.assert.deepEqual(this.get("tokens-value"), "")
+ this.assert.deepEqual(this.controller.tokensValue, [])
+
+ this.assert.throws(() => (this.controller.tokensValue = "not an array" as any))
+ this.assert.throws(() => (this.controller.tokensValue = null as any))
+ this.assert.throws(() => (this.controller.tokensValue = ["foo bar"]))
+ this.assert.throws(() => (this.controller.tokensValue = [""]))
+ }
+
"test accessing a string value returns the empty string when the attribute is missing"() {
this.controller.stringValue = undefined as any
this.assert.notOk(this.has("string-value"))
@@ -150,6 +191,15 @@ export default class ValueTests extends ControllerTestCase(ValueController) {
this.assert.deepEqual(this.controller.optionsValue, {})
}
+ "test accessing a list value returns an empty array when the attribute is missing"() {
+ this.controller.tokensValue = undefined as any
+ this.assert.notOk(this.has("tokens-value"))
+ this.assert.deepEqual(this.controller.tokensValue, [])
+
+ this.controller.tokensValue.push("x")
+ this.assert.deepEqual(this.controller.tokensValue, [])
+ }
+
async "test changed callbacks"() {
this.assert.deepEqual(this.controller.loggedNumericValues, [123])
this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0])
@@ -163,6 +213,16 @@ export default class ValueTests extends ControllerTestCase(ValueController) {
await this.nextFrame
this.assert.deepEqual(this.controller.loggedNumericValues, [123, 0, 1])
this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0, 123, 0])
+
+ this.set("numeric-value", "")
+ await this.nextFrame
+ this.assert.deepEqual(this.controller.loggedNumericValues, [123, 0, 1, 0])
+ this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0, 123, 0, 1])
+
+ this.set("numeric-value", "5")
+ await this.nextFrame
+ this.assert.deepEqual(this.controller.loggedNumericValues, [123, 0, 1, 0, 5])
+ this.assert.deepEqual(this.controller.oldLoggedNumericValues, [0, 123, 0, 1, 0])
}
async "test changed callbacks for object"() {
@@ -191,6 +251,21 @@ export default class ValueTests extends ControllerTestCase(ValueController) {
])
}
+ async "test changed callbacks for list"() {
+ this.assert.deepEqual(this.controller.loggedTokensValues, [["one", "two", "three"]])
+ this.assert.deepEqual(this.controller.oldLoggedTokensValues, [[]])
+
+ this.controller.tokensValue = ["four"]
+ await this.nextFrame
+ this.assert.deepEqual(this.controller.loggedTokensValues, [["one", "two", "three"], ["four"]])
+ this.assert.deepEqual(this.controller.oldLoggedTokensValues, [[], ["one", "two", "three"]])
+
+ this.set("tokens-value", "five six")
+ await this.nextFrame
+ this.assert.deepEqual(this.controller.loggedTokensValues, [["one", "two", "three"], ["four"], ["five", "six"]])
+ this.assert.deepEqual(this.controller.oldLoggedTokensValues, [[], ["one", "two", "three"], ["four"]])
+ }
+
async "test default values trigger changed callbacks"() {
this.assert.deepEqual(this.controller.loggedMissingStringValues, [""])
this.assert.deepEqual(this.controller.oldLoggedMissingStringValues, [undefined])